Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
|
import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext';
|
|
import { ProofSessionControls } from '../ProofSessionControls';
|
|
|
|
/**
|
|
* The review dialog of a client proofing link. The selection ZIP is built from
|
|
* what the server has saved, so it must not be offered while a heart is still
|
|
* waiting out the autosave debounce; and the download counter has to follow
|
|
* the downloads started from this page.
|
|
*/
|
|
|
|
const TOKEN = 'k'.repeat(32);
|
|
|
|
function Heart() {
|
|
const p = useProofing()!;
|
|
return <button onClick={() => p.toggleFavorite('t1')}>heart t1</button>;
|
|
}
|
|
|
|
function renderControls(overrides: Partial<ProofSessionInit> = {}) {
|
|
return render(
|
|
<ProofingProvider
|
|
albumTokens={['t1', 't2', 't3']}
|
|
albumName="Wedding"
|
|
session={{
|
|
token: TOKEN,
|
|
selected: ['t3'],
|
|
submitted: false,
|
|
download: 'album',
|
|
downloadsRemaining: null,
|
|
...overrides,
|
|
}}
|
|
>
|
|
<Heart />
|
|
<ProofSessionControls />
|
|
</ProofingProvider>,
|
|
);
|
|
}
|
|
|
|
const selectionLink = () => screen.queryByText('Download selection (.zip)');
|
|
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const store = new Map<string, string>();
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
|
removeItem: (k: string) => void store.delete(k),
|
|
clear: () => store.clear(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('ProofSessionControls', () => {
|
|
it('offers the selection ZIP when nothing is waiting to be saved', () => {
|
|
renderControls();
|
|
fireEvent.click(screen.getByText('Review & submit'));
|
|
expect(selectionLink()).not.toBeNull();
|
|
});
|
|
|
|
it('withholds the selection ZIP while a heart waits out the autosave debounce', async () => {
|
|
renderControls();
|
|
// A first save, so the state reads 'saved' rather than 'idle'.
|
|
fireEvent.click(screen.getByText('heart t1'));
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Another heart, and the dialog opened before its save is sent.
|
|
fireEvent.click(screen.getByText('heart t1'));
|
|
fireEvent.click(screen.getByText('Review & submit'));
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(selectionLink()).toBeNull();
|
|
|
|
// Once the save lands the link comes back, now for the saved selection.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(selectionLink()).not.toBeNull();
|
|
});
|
|
|
|
it('counts a started download against the limit shown', () => {
|
|
renderControls({ downloadsRemaining: 2 });
|
|
fireEvent.click(screen.getByText('Review & submit'));
|
|
expect(screen.getByText('2 downloads left')).toBeTruthy();
|
|
|
|
const link = screen.getByText('Download all photos (.zip)');
|
|
// jsdom does not navigate; keep it from trying.
|
|
link.addEventListener('click', (e) => e.preventDefault());
|
|
fireEvent.click(link);
|
|
expect(screen.getByText('1 download left')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByText('Download all photos (.zip)'));
|
|
expect(screen.queryByText('Download all photos (.zip)')).toBeNull();
|
|
expect(selectionLink()).toBeNull();
|
|
});
|
|
});
|