// @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)}
p.toggleFavorite('t1')}>t1
p.toggleFavorite('t2')}>t2
void p.session?.submit()}>submit
);
}
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');
});
});