import { describe, expect, it } from 'vitest'; import type { Task } from '@/lib/types'; import { acceptSnapshot, assertMutable, canMutate, combineFreshness, computeDigest, computeFreshness, DEFAULT_FRESHNESS_POLICY, formatAge, type FreshSnapshot, invalidationReasonLabels, StaleMutationError, UNKNOWN_VERDICT, verdictValue, } from './model'; import { validateProjectCollection, validateTaskCollection } from './validators'; const NOW = 1_800_000_000_000; const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; const taskPayload: Task[] = [ { id: 'task-1', title: 'T1', description: null, status: 'not-started', priority: 'high', projectId: 'project-1', missionId: null, assignee: null, tags: null, dueDate: null, metadata: null, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', }, ]; function acceptedTaskSnapshot( overrides: Partial> = {}, ): FreshSnapshot { const result = acceptSnapshot({ value: taskPayload, validate: validateTaskCollection, previous: null, policy, source: 'gateway:/api/tasks', now: NOW, }); if (result.outcome !== 'accepted') { throw new Error(`fixture setup failed: ${result.reason}`); } return { ...result.snapshot, ...overrides }; } describe('computeFreshness', () => { it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => { expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable'); }); it('returns current for a fresh verified snapshot regardless of data emptiness', () => { const empty = acceptSnapshot({ value: [], validate: validateTaskCollection, previous: null, policy, source: 'gateway:/api/tasks', now: NOW, }); if (empty.outcome !== 'accepted') throw new Error('expected acceptance'); expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current'); }); it('degrades to stale once the snapshot ages past staleAfterMs', () => { const snapshot = acceptedTaskSnapshot(); expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale'); expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current'); }); it('degrades to stale when the latest revalidation failed', () => { const snapshot = acceptedTaskSnapshot(); expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale'); }); }); describe('mutation guard', () => { it('permits mutations only on current data', () => { expect(canMutate('current')).toBe(true); for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { expect(canMutate(state)).toBe(false); } }); it('refuses mutations on non-current data via assertMutable', () => { expect(() => assertMutable('current')).not.toThrow(); for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { let thrown: unknown; try { assertMutable(state); } catch (caught) { thrown = caught; } expect(thrown).toBeInstanceOf(StaleMutationError); expect(thrown).toBeInstanceOf(Error); if (thrown instanceof StaleMutationError) { expect(thrown.name).toBe('StaleMutationError'); expect(thrown.freshness).toBe(state); expect(thrown.message).toContain(state); expect(thrown.message).toContain('revalidat'); } } }); }); describe('acceptSnapshot', () => { it('accepts a valid payload with provenance', () => { const result = acceptSnapshot({ value: taskPayload, validate: validateTaskCollection, previous: null, policy, source: 'gateway:/api/tasks', now: NOW, }); expect(result.outcome).toBe('accepted'); if (result.outcome !== 'accepted') return; expect(result.snapshot.source).toBe('gateway:/api/tasks'); expect(result.snapshot.version).toBe(1); expect(result.snapshot.fetchedAt).toBe(NOW); expect(result.snapshot.data).toEqual(taskPayload); }); it('invalidates a schema-mismatched payload instead of rendering it', () => { const result = acceptSnapshot({ value: { not: 'an array' }, validate: validateTaskCollection, previous: acceptedTaskSnapshot(), policy, source: 'gateway:/api/tasks', now: NOW, }); expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); expect(invalidationReasonLabels['schema-mismatch']).toContain('schema'); }); it('invalidates cross-workspace payloads', () => { const userOne = acceptSnapshot({ value: [ { id: 'p1', name: 'P1', description: null, status: 'active', userId: 'user-1', metadata: null, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', }, ], validate: validateProjectCollection, previous: null, policy, source: 'gateway:/api/projects', now: NOW, }); if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); const switched = acceptSnapshot({ value: [ { id: 'p9', name: 'P9', description: null, status: 'active', userId: 'user-2', metadata: null, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', }, ], validate: validateProjectCollection, previous: userOne.snapshot, policy, source: 'gateway:/api/projects', now: NOW, }); expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); }); it('keeps the previous workspace for collections with no intrinsic identity', () => { const userOne = acceptSnapshot({ value: [ { id: 'p1', name: 'P1', description: null, status: 'active', userId: 'user-1', metadata: null, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', }, ], validate: validateProjectCollection, previous: null, policy, source: 'gateway:/api/projects', now: NOW, }); if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); // Empty list after the user deleted every project: no identity to check, // so the verified scope is retained and the empty state stays healthy. const emptied = acceptSnapshot({ value: [], validate: validateProjectCollection, previous: userOne.snapshot, policy, source: 'gateway:/api/projects', now: NOW, }); expect(emptied.outcome).toBe('accepted'); if (emptied.outcome === 'accepted') { expect(emptied.snapshot.data).toEqual([]); expect(emptied.snapshot.workspace).toBe('user-1'); } }); it('invalidates version regressions', () => { const previous = acceptedTaskSnapshot({ version: 7 }); const regressed = acceptSnapshot({ value: taskPayload, validate: validateTaskCollection, previous, policy, source: 'gateway:/api/tasks', now: NOW, incomingVersion: 3, }); expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 }); const downgradedClient = acceptSnapshot({ value: taskPayload, validate: validateTaskCollection, previous: newerSchema, policy: { ...policy, schemaVersion: 2 }, source: 'gateway:/api/tasks', now: NOW, }); expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); }); it('increments the version monotonically across accepted snapshots', () => { const first = acceptedTaskSnapshot(); const second = acceptSnapshot({ value: taskPayload, validate: validateTaskCollection, previous: first, policy, source: 'gateway:/api/tasks', now: NOW, }); expect(second.outcome).toBe('accepted'); if (second.outcome === 'accepted') { expect(second.snapshot.version).toBe(first.version + 1); } }); }); describe('combineFreshness', () => { it('gates the surface on the primary collection', () => { expect(combineFreshness('unavailable', ['current'])).toBe('unavailable'); expect(combineFreshness('unknown', ['current'])).toBe('unknown'); expect(combineFreshness('current', [])).toBe('current'); }); it('degrades to partial when a secondary is unavailable', () => { expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial'); }); it('degrades to unknown while a secondary is still loading', () => { expect(combineFreshness('current', ['unknown'])).toBe('unknown'); }); it('degrades to stale when any collection is stale', () => { expect(combineFreshness('current', ['stale'])).toBe('stale'); expect(combineFreshness('stale', ['current'])).toBe('stale'); }); it('propagates partial secondaries', () => { expect(combineFreshness('current', ['partial'])).toBe('partial'); }); }); describe('computeDigest', () => { it('is stable across key order and changes with data', () => { const a = computeDigest({ x: 1, y: [1, 2] }); const b = computeDigest({ y: [1, 2], x: 1 }); expect(a).toBe(b); expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a); }); }); describe('verdictValue', () => { it('returns the value only for verified inputs', () => { expect(verdictValue(true, '5')).toBe('5'); expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT); expect(verdictValue(false, '5')).not.toBe('5'); }); }); describe('formatAge', () => { it('labels age in human terms', () => { expect(formatAge(NOW, NOW)).toBe('just now'); expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago'); expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago'); expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago'); expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago'); }); });