373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
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<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement;
|
|
let latest: FreshCollection<Task[]> | null = null;
|
|
|
|
function Probe({
|
|
options,
|
|
}: {
|
|
options: UseFreshCollectionOptions<Task[]>;
|
|
}): React.ReactElement | null {
|
|
latest = useFreshCollection<Task[]>(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<Task[]>,
|
|
): Promise<FreshCollection<Task[]>> {
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root?.render(<Probe options={options} />);
|
|
});
|
|
if (latest === null) throw new Error('hook did not run');
|
|
return latest;
|
|
}
|
|
|
|
function taskOptions(
|
|
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
|
|
): UseFreshCollectionOptions<Task[]> {
|
|
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<Task[]>();
|
|
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<Task[]>();
|
|
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<Task[]>();
|
|
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' });
|
|
});
|
|
});
|