import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Task } from '@/lib/types'; import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model'; import type { FreshnessFailure } from './use-fresh-collection'; import { describeFailure, useFreshCollection, type FreshCollection, type UseFreshCollectionOptions, } from './use-fresh-collection'; import { validateProjectCollection, validateTaskCollection } from './validators'; import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; /** * Failure-matrix coverage for the freshness seam (RI-5-001): network failure, * auth failure, malformed response, cache corruption, stale age, schema * mismatch, cross-workspace, recovery, and stale-action rejection — with * negative controls proving no case yields current data or an enabled * mutation. */ const NOW = 1_800_000_000_000; interface Deferred { promise: Promise; resolve: (value: T) => void; reject: (reason?: unknown) => void; } function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } let root: Root | null = null; let container: HTMLDivElement; let latest: FreshCollection | null = null; function Probe({ options, }: { options: UseFreshCollectionOptions; }): React.ReactElement | null { latest = useFreshCollection(options); return null; } beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); beforeEach(() => { sessionStorage.clear(); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); root = null; latest = null; sessionStorage.clear(); vi.restoreAllMocks(); }); async function renderCollection( options: UseFreshCollectionOptions, ): Promise> { container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); if (latest === null) throw new Error('hook did not run'); return latest; } function taskOptions( overrides: Partial> = {}, ): UseFreshCollectionOptions { return { source: 'gateway:/api/tasks', fetcher: () => Promise.resolve(taskFixtures), validate: validateTaskCollection, cacheKey: 'tasks', clock: () => NOW, ...overrides, }; } function authError(statusCode: number): Error & { statusCode: number } { return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode }); } function seedCache(key: string): number { const result = acceptSnapshot({ value: taskFixtures, validate: validateTaskCollection, previous: null, policy: DEFAULT_FRESHNESS_POLICY, source: 'gateway:/api/tasks', now: NOW, }); if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot })); return result.snapshot.version; } describe('useFreshCollection failure matrix', () => { it('is unknown (not empty) while the first validation is in flight', async () => { const deferred = createDeferred(); const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); expect(collection.freshness).toBe('unknown'); expect(collection.validating).toBe(true); expect(collection.data).toBeNull(); expect(collection.canMutate).toBe(false); await act(async () => { deferred.resolve(taskFixtures); await deferred.promise; }); }); it('becomes current with provenance after a verified fetch', async () => { const collection = await renderCollection(taskOptions()); expect(collection.freshness).toBe('current'); expect(collection.data).toEqual(taskFixtures); expect(collection.snapshot?.source).toBe('gateway:/api/tasks'); expect(collection.snapshot?.version).toBe(1); expect(collection.failure).toBeNull(); expect(collection.canMutate).toBe(true); // Verified snapshot is persisted for last-known restore. expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy(); }); it('treats a network failure as unavailable — never an empty healthy collection', async () => { const collection = await renderCollection( taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }), ); expect(collection.freshness).toBe('unavailable'); expect(collection.data).toBeNull(); expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' }); expect(collection.canMutate).toBe(false); expect(describeFailure(collection.failure)).toBe('network down'); }); it('treats an auth failure as unavailable and drops the last-known snapshot', async () => { let call = 0; const collection = await renderCollection( taskOptions({ fetcher: () => { call += 1; return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401)); }, }), ); expect(collection.freshness).toBe('current'); await act(async () => { await collection.revalidate(); }); expect(latest?.freshness).toBe('unavailable'); expect(latest?.data).toBeNull(); expect(latest?.failure?.kind).toBe('fetch'); // The previous user's data must not linger in the session cache. expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); }); it('invalidates a malformed response as a schema mismatch', async () => { const collection = await renderCollection( taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }), ); expect(collection.freshness).toBe('unavailable'); expect(collection.data).toBeNull(); expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); expect(collection.canMutate).toBe(false); }); it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => { let call = 0; const collection = await renderCollection( taskOptions({ fetcher: () => { call += 1; return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage'); }, }), ); expect(collection.freshness).toBe('current'); await act(async () => { await collection.revalidate(); }); expect(latest?.freshness).toBe('stale'); expect(latest?.data).toEqual(taskFixtures); expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); expect(latest?.canMutate).toBe(false); }); it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => { let call = 0; const collection = await renderCollection( taskOptions({ fetcher: () => { call += 1; return Promise.resolve( call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }], ); }, validate: validateProjectCollection as unknown as (value: unknown) => { data: Task[]; workspace: string | null; }, source: 'gateway:/api/projects', }), ); expect(collection.freshness).toBe('current'); await act(async () => { await collection.revalidate(); }); expect(latest?.freshness).toBe('unavailable'); expect(latest?.data).toBeNull(); expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' }); }); it('ages from current to stale and refuses mutations on stale data', async () => { let fakeNow = NOW; const collection = await renderCollection( taskOptions({ clock: () => fakeNow, policy: { staleAfterMs: 40 }, tickMs: 10, }), ); expect(collection.freshness).toBe('current'); // Age the snapshot past the policy and let the tick recompute. fakeNow = NOW + 60; await act(async () => { await new Promise((resolve) => setTimeout(resolve, 25)); }); expect(latest?.freshness).toBe('stale'); expect(latest?.data).toEqual(taskFixtures); expect(latest?.canMutate).toBe(false); const operation = vi.fn(async () => 'result'); await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); expect(operation).not.toHaveBeenCalled(); }); it('recovers to current after a successful revalidation', async () => { let call = 0; const collection = await renderCollection( taskOptions({ fetcher: () => { call += 1; return call === 1 ? Promise.reject(new Error('first attempt failed')) : Promise.resolve(taskFixtures); }, }), ); expect(collection.freshness).toBe('unavailable'); await act(async () => { await collection.revalidate(); }); expect(latest?.freshness).toBe('current'); expect(latest?.failure).toBeNull(); const operation = vi.fn(async (data: Task[]) => data.length); await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length); expect(operation).toHaveBeenCalledOnce(); }); it('restores a cached snapshot as unverified stale data, then verifies it', async () => { const seededVersion = seedCache('tasks'); const deferred = createDeferred(); const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); // Restored data is situational awareness only: labeled stale, never // current, and mutations are refused before verification. expect(collection.freshness).toBe('stale'); expect(collection.data).toEqual(taskFixtures); expect(collection.canMutate).toBe(false); await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError); await act(async () => { deferred.resolve(taskFixtures); await deferred.promise; }); expect(latest?.freshness).toBe('current'); expect(latest?.snapshot?.version).toBe(seededVersion + 1); }); it('never promotes corrupted cache data to current (cache corruption)', async () => { sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":'); const collection = await renderCollection( taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }), ); expect(collection.freshness).toBe('unavailable'); expect(collection.data).toBeNull(); expect(collection.canMutate).toBe(false); // The corrupted entry is dropped so it cannot come back. expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); }); it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => { const deferred = createDeferred(); const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); const operation = vi.fn(async () => 'result'); await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); expect(operation).not.toHaveBeenCalled(); await act(async () => { deferred.reject(new Error('failed')); await deferred.promise.catch(() => undefined); }); const unavailable = latest!; await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); expect(operation).not.toHaveBeenCalled(); expect(unavailable.canMutate).toBe(false); }); it('degrades to stale with last-known data when a revalidation fails after success', async () => { let call = 0; const collection = await renderCollection( taskOptions({ fetcher: () => { call += 1; return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(new Error('connection lost')); }, }), ); expect(collection.freshness).toBe('current'); await act(async () => { await collection.revalidate(); }); expect(latest?.freshness).toBe('stale'); expect(latest?.data).toEqual(taskFixtures); const failure: FreshnessFailure | null = latest?.failure ?? null; expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' }); }); });