Assessor_2.0/tests/postgres/revision-read-cache.test.ts

95 lines
5.8 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { Pool } from 'pg';
import { RevisionReadCache } from '../../server/postgres/revision-read-cache';
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
import { queryUnifiedResultItems, type UnifiedResultProjection } from '../../src/domain/unified-result';
describe('revision-checked read reuse', () => {
it('shares concurrent reads and validates both sides of every cache hit', async () => {
const cache = new RevisionReadCache<string[]>();
const revision = vi.fn(async () => 7);
const load = vi.fn(async () => ['complete', 'original']);
expect(await Promise.all([cache.read('scope', revision, load), cache.read('scope', revision, load)]))
.toEqual([['complete', 'original'], ['complete', 'original']]);
await cache.read('scope', revision, load);
expect(load).toHaveBeenCalledTimes(1);
expect(revision).toHaveBeenCalledTimes(6);
});
it('invalidates after commits and discards a read spanning two revisions', async () => {
const cache = new RevisionReadCache<number>();
let revision = 1;
const load = vi.fn(async () => { const value = revision; if (revision === 1) revision = 2; return value; });
expect(await cache.read('scope', async () => revision, load)).toBe(2);
revision = 3;
expect(await cache.read('scope', async () => revision, load)).toBe(3);
expect(load).toHaveBeenCalledTimes(3);
});
it('refuses unverified or repeatedly changing data and retries failed loads', async () => {
const cache = new RevisionReadCache<number>();
await expect(cache.read('scope', async () => -1, async () => 1)).rejects.toThrow('Cannot verify');
let revision = 1;
await expect(cache.read('scope', async () => revision, async () => ++revision)).rejects.toThrow('changed repeatedly');
const load = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValue(42);
await expect(cache.read('scope', async () => revision, load)).rejects.toThrow('read failed');
expect(await cache.read('scope', async () => revision, load)).toBe(42);
});
it('bounds entry count and retained bytes without truncating oversized results', async () => {
const cache = new RevisionReadCache<string>(1, 20);
const revision = async () => 1;
const load = vi.fn(async () => 'full original value larger than capacity');
expect(await cache.read('large', revision, load)).toBe(await cache.read('large', revision, load));
expect(load).toHaveBeenCalledTimes(2);
const small = vi.fn(async () => 'small');
await cache.read('a', revision, small);
await cache.read('b', revision, small);
await cache.read('a', revision, small);
expect(small).toHaveBeenCalledTimes(3);
});
});
describe('PostgreSQL paginated client reads', () => {
it('preserves every field, ordering and count across pages; refreshes same-count edits and separates scopes', async () => {
let revision = 7;
const pool = { query: vi.fn(async (sql: string) => ({ rows: sql.startsWith('SHOW')
? [{ default_transaction_read_only: 'on' }] : [{ revision: String(revision) }] })) } as unknown as Pool;
const repository = await PostgresAssessorRepository.openReadOnly(pool);
const rows = Array.from({ length: 2501 }, (_, index) => ({
resultProjectionId: `finding-${String(index).padStart(5, '0')}`, projectId: 'synthetic', assessmentId: 'a',
scanRunId: 'scan', sourceKind: 'scanner-finding', severityNormalized: index % 2 ? 'high' : 'low',
importedAt: '2026-10-02T00:00:00Z', outcomeNormalized: 'fail', title: `Original ${index}`,
description: 'Complete source text', categories: [{ namespace: 'source', categoryCode: 'native', categoryLabel: 'Native' }],
policyValues: [{ original: 'retained evidence' }]
} as unknown as UnifiedResultProjection));
const load = vi.spyOn(repository as unknown as { loadInternalUnifiedResults(): Promise<UnifiedResultProjection[]> },
'loadInternalUnifiedResults').mockImplementation(async () => structuredClone(rows));
const query = { audience: 'internal-analysis' as const, projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['scan'] };
const actual: UnifiedResultProjection[] = [];
for (let offset = 0; offset < rows.length; offset += 1000) {
const page = await repository.queryUnifiedResults({ ...query, offset, limit: 1000 });
expect(page).toEqual(queryUnifiedResultItems(rows, { ...query, offset, limit: 1000 }));
actual.push(...page.items);
}
expect(actual).toHaveLength(2501);
expect(new Set(actual.map(row => row.resultProjectionId)).size).toBe(2501);
expect(load).toHaveBeenCalledTimes(1); // Previously: full source reads for each of three pages.
actual[0].categories[0].categoryLabel = 'consumer mutation';
expect((await repository.queryUnifiedResults(query)).items[0].categories[0].categoryLabel).toBe('Native');
rows[0].title = 'Updated source';
revision += 1; // A worker commit, independent of repository.cachedRevision.
expect(await repository.queryUnifiedResults(query)).toEqual(queryUnifiedResultItems(rows, query));
expect(load).toHaveBeenCalledTimes(2);
await repository.queryUnifiedResults({ ...query, scanRunIds: ['different'] });
await repository.queryUnifiedResults({ ...query, assessmentIds: ['different'] });
await repository.queryUnifiedResults({ ...query, projectId: 'different' });
expect(load).toHaveBeenCalledTimes(5);
const disclosed = vi.spyOn(repository as unknown as { storedResultDbRows(): Promise<UnifiedResultProjection[]> },
'storedResultDbRows').mockResolvedValue([]);
expect((await repository.queryUnifiedResults({ projectId: 'synthetic' })).items).toEqual([]);
expect(disclosed).toHaveBeenCalledTimes(1);
expect(load).toHaveBeenCalledTimes(5); // Internal evidence never supplies Result DB disclosure.
});
});