325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
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<typeof taskPayload>> = {},
|
|
): FreshSnapshot<typeof taskPayload> {
|
|
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');
|
|
});
|
|
});
|