import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model'; import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; import { validateProjectCollection, validateTaskCollection } from './validators'; import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; import type { Project, Task } from '@/lib/types'; const KEY = 'test:tasks'; const NOW = 1_800_000_000_000; const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; function storedTaskSnapshot() { const result = acceptSnapshot({ value: taskFixtures, validate: validateTaskCollection, previous: null, policy, source: 'gateway:/api/tasks', now: NOW, }); if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); return result.snapshot; } function storedProjectSnapshot() { const result = acceptSnapshot({ value: projectFixtures, validate: validateProjectCollection, previous: null, policy, source: 'gateway:/api/projects', now: NOW, }); if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); return result.snapshot; } function readTasks() { return readSnapshotCache({ key: KEY, workspace: policy.workspace, policy, validate: validateTaskCollection, }); } /** Write an arbitrary value directly at the raw cache slot. */ function writeRaw(key: string, value: unknown): void { sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value)); } /** Parse and re-write the stored entry (for tampering with internals). */ function tamperStored(key: string, mutate: (stored: T) => void): void { const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T; mutate(parsed); writeRaw(key, parsed); } beforeEach(() => { sessionStorage.clear(); }); afterEach(() => { sessionStorage.clear(); }); describe('readSnapshotCache', () => { it('misses when nothing is stored', () => { expect(readTasks()).toEqual({ outcome: 'miss' }); }); it('hits for a well-formed entry and preserves provenance', () => { const snapshot = storedTaskSnapshot(); writeSnapshotCache(KEY, snapshot); const result = readTasks(); expect(result.outcome).toBe('hit'); if (result.outcome === 'hit') { expect(result.snapshot.data).toEqual(taskFixtures); expect(result.snapshot.source).toBe('gateway:/api/tasks'); expect(result.snapshot.version).toBe(snapshot.version); expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt); expect(result.snapshot.workspace).toBe(snapshot.workspace); } }); it('invalidates unparsable entries as cache corruption', () => { sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json'); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); }); it('invalidates structurally wrong entries as cache corruption', () => { const malformed: unknown[] = [ 'nested but not a snapshot', { data: taskFixtures }, // missing provenance fields { data: taskFixtures, source: 1, workspace: 'w', version: 1, schemaVersion: 1, fetchedAt: 1, digest: 'x', }, null, 17, ]; for (const entry of malformed) { writeRaw(KEY, entry); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); } }); it('invalidates digest mismatches as cache corruption (tampered data)', () => { writeSnapshotCache(KEY, storedTaskSnapshot()); tamperStored<{ data: Task[] }>(KEY, (stored) => { stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }]; }); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); }); it('invalidates entries scoped to another workspace', () => { const snapshot = storedTaskSnapshot(); writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' }); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); }); it('invalidates entries written by a newer schema as a version regression', () => { const snapshot = storedTaskSnapshot(); writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 }); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); }); it('invalidates entries whose data no longer validates (schema mismatch)', () => { writeSnapshotCache(KEY, storedTaskSnapshot()); tamperStored<{ data: unknown }>(KEY, (stored) => { stored.data = { malformed: true }; }); expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); }); it('never reports a corrupted raw entry as a hit (negative control)', () => { for (const raw of ['{oops', 'null', '"string"', '[]', '12']) { sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw); const result = readTasks(); expect(result.outcome).not.toBe('hit'); expect(result.outcome).toBe('invalidated'); } }); it('scopes project collections by their workspace identity', () => { const snapshot = storedProjectSnapshot(); writeSnapshotCache('test:projects', snapshot); const sameScope = readSnapshotCache({ key: 'test:projects', workspace: 'user-1', policy, validate: validateProjectCollection, }); expect(sameScope.outcome).toBe('hit'); const foreignScope = readSnapshotCache({ key: 'test:projects', workspace: 'user-2', policy, validate: validateProjectCollection, }); expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); }); }); describe('writeSnapshotCache round-trip', () => { it('round-trips an accepted project snapshot', () => { const snapshot = storedProjectSnapshot(); writeSnapshotCache('test:projects', snapshot); const result = readSnapshotCache({ key: 'test:projects', workspace: snapshot.workspace, policy, validate: validateProjectCollection, }); expect(result.outcome).toBe('hit'); if (result.outcome === 'hit') { expect(result.snapshot.data).toEqual(projectFixtures as Project[]); } }); }); describe('clearSnapshotCache', () => { it('drops the entry so the next read misses', () => { writeSnapshotCache(KEY, storedTaskSnapshot()); expect(readTasks().outcome).toBe('hit'); clearSnapshotCache(KEY); expect(readTasks()).toEqual({ outcome: 'miss' }); }); });