95 lines
5.8 KiB
TypeScript
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.
|
|
});
|
|
});
|