107 lines
6.8 KiB
TypeScript
107 lines
6.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { AiOverviewService } from '../../server/ai/ai-overview-service';
|
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import { validAiOverviewResult } from '../fixtures/ai-overview';
|
|
|
|
const stamp = '2026-09-14T08:00:00.000Z';
|
|
|
|
function syntheticResult(): UnifiedResultProjection {
|
|
return {
|
|
resultProjectionId: 'projection-1', sourceKind: 'scanner-finding', sourceResultId: 'occurrence-1',
|
|
projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'run-1', scanRunId: 'run-1',
|
|
sourceTool: 'Synthetic Scanner', sourceRuleId: 'RULE-1', title: 'Synthetic finding', description: 'Synthetic description',
|
|
recommendation: 'Synthetic recommendation', severityOriginal: 'high', severityNormalized: 'high',
|
|
outcomeOriginal: 'fail', outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'compliance_check',
|
|
isIoe: false, isDeviation: true, lifecycleState: 'new', mappingConfidence: 1,
|
|
reviewStatus: 'unreviewed', importedAt: stamp, createdAt: stamp, categories: []
|
|
};
|
|
}
|
|
|
|
describe('AI overview service', () => {
|
|
it('uses the complete timeslot query, persists a successful run, and honors idempotency', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
|
|
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
|
|
const query = vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
|
|
const serviceResult = validAiOverviewResult();
|
|
serviceResult.coverageGaps = [];
|
|
serviceResult.environmentOverview.factsUsed = [];
|
|
const complete = vi.fn(async () => ({ value: serviceResult, usage: { totalTokens: 30 } }));
|
|
const service = new AiOverviewService(repository, {
|
|
client: { model: 'work-model', complete }, now: () => new Date(stamp)
|
|
});
|
|
|
|
const request = { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-1234' };
|
|
const started = await service.start('project-1', request);
|
|
expect(started.state).toBe('queued');
|
|
await service.close();
|
|
|
|
const completed = await service.get('project-1', request);
|
|
expect(completed).toMatchObject({ state: 'succeeded', stale: false, run: { model: 'work-model', callCount: 1, tokenUsage: { totalTokens: 30 } } });
|
|
expect(query).toHaveBeenCalledWith({
|
|
audience: 'result-db', projectId: 'project-1', assessmentIds: ['assessment-1'], scanRunIds: [], offset: 0, limit: 500
|
|
});
|
|
expect((await repository.exportProject('project-1')).aiOverviewRuns).toHaveLength(1);
|
|
|
|
await service.start('project-1', request);
|
|
await service.close();
|
|
expect(complete).toHaveBeenCalledTimes(1);
|
|
expect(await repository.listAiOverviewRuns('project-1')).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps the previous success when a later regeneration fails', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
|
|
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
|
|
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
|
|
const serviceResult = validAiOverviewResult();
|
|
serviceResult.coverageGaps = [];
|
|
serviceResult.environmentOverview.factsUsed = [];
|
|
const complete = vi.fn()
|
|
.mockResolvedValueOnce({ value: serviceResult })
|
|
.mockResolvedValueOnce({ value: { schemaVersion: 1 } });
|
|
let currentTime = stamp;
|
|
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(currentTime) });
|
|
|
|
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
|
|
await service.close();
|
|
currentTime = '2026-09-14T09:00:00.000Z';
|
|
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' });
|
|
await service.close();
|
|
|
|
const runs = await repository.listAiOverviewRuns('project-1');
|
|
expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']);
|
|
expect(runs.find(run => run.status === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.');
|
|
expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid');
|
|
const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] });
|
|
expect(response).toMatchObject({
|
|
state: 'failed', run: { errorCode: 'ai_output_invalid' },
|
|
previousSuccessfulRun: { result: { executiveSummary: 'Synthetic executive summary.' } }
|
|
});
|
|
});
|
|
|
|
it('marks the previous success stale while a regeneration is queued', async () => {
|
|
const repository = new LocalAssessorRepository();
|
|
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
|
|
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
|
|
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
|
|
const serviceResult = validAiOverviewResult();
|
|
serviceResult.coverageGaps = [];
|
|
serviceResult.environmentOverview.factsUsed = [];
|
|
const complete = vi.fn().mockResolvedValueOnce({ value: serviceResult });
|
|
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) });
|
|
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
|
|
await service.close();
|
|
const [saved] = await repository.listAiOverviewRuns('project-1');
|
|
await repository.createAiOverviewRun({
|
|
...saved, id: 'ai-overview-queued', idempotencyKey: 'request-queued', status: 'queued', inputHash: 'b'.repeat(64),
|
|
result: undefined, completedAt: undefined, createdAt: '2026-09-14T09:00:00.000Z', updatedAt: '2026-09-14T09:00:00.000Z', callCount: 0
|
|
});
|
|
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [{ ...syntheticResult(), title: 'Changed synthetic finding' }] });
|
|
|
|
const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] });
|
|
expect(response).toMatchObject({ state: 'queued', stale: true, previousSuccessfulRun: { id: saved.id } });
|
|
});
|
|
});
|