198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
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<T>(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' });
|
|
});
|
|
});
|