// @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'; /** * Session mode of the proofing context: the selection starts from the server, * a burst of hearts becomes one save, and a submitted selection stays put. */ function Probe() { const p = useProofing()!; return (
{p.favorites.size} {p.session?.saveState} {String(p.session?.submitted)}
); } const TOKEN = 'k'.repeat(32); function renderSession(overrides: Partial = {}) { return render( , ); } let fetchMock: ReturnType; beforeEach(() => { vi.useFakeTimers(); fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); // Node 26 defines its own global localStorage (undefined without // --localstorage-file), which shadows jsdom's; give the test a working one. const store = new Map(); 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('ProofingProvider session mode', () => { it('starts from the server selection, not localStorage', () => { localStorage.setItem('folio_fav_wedding', JSON.stringify(['t1', 't2'])); renderSession(); expect(screen.getByTestId('count').textContent).toBe('1'); }); it('debounces hearts into one save, in album order', async () => { renderSession(); fireEvent.click(screen.getByText('t2')); fireEvent.click(screen.getByText('t1')); expect(fetchMock).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe(`/api/proof/${TOKEN}/selection`); expect(init.method).toBe('PUT'); expect(JSON.parse(init.body as string)).toEqual({ assets: ['t1', 't2', 't3'] }); expect(screen.getByTestId('state').textContent).toBe('saved'); }); it('reports a failed save', async () => { fetchMock.mockResolvedValue(new Response('{}', { status: 500 })); renderSession(); fireEvent.click(screen.getByText('t1')); await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); expect(screen.getByTestId('state').textContent).toBe('error'); }); it('saves pending hearts before submitting, then locks', async () => { renderSession(); fireEvent.click(screen.getByText('t1')); await act(async () => { fireEvent.click(screen.getByText('submit')); await vi.advanceTimersByTimeAsync(0); }); const urls = fetchMock.mock.calls.map((call) => call[0]); expect(urls).toEqual([`/api/proof/${TOKEN}/selection`, `/api/proof/${TOKEN}/submit`]); expect(screen.getByTestId('submitted').textContent).toBe('true'); fireEvent.click(screen.getByText('t2')); expect(screen.getByTestId('count').textContent).toBe('2'); }); it('ignores hearts on a submitted selection', () => { renderSession({ submitted: true }); fireEvent.click(screen.getByText('t1')); expect(screen.getByTestId('count').textContent).toBe('1'); }); });