import { describe, expect, it } from 'vitest'; import { aiOverviewResultValidationErrors, aiOverviewScopeKey, currentManagementBrief, normalizeAiOverviewScope, type AiOverviewResponse } from '../../src/domain/ai-overview'; import { generateAiOverview, splitPrivacySafeInput, type PrivacySafeInput } from '../../server/ai/ai-overview-service'; import { validAiOverviewResult } from '../fixtures/ai-overview'; describe('AI overview contract', () => { it('normalizes timeslot identity deterministically', () => { const scope = normalizeAiOverviewScope({ assessmentIds: ['b', 'a', 'a'], scanRunIds: ['2', '1'] }); expect(scope).toEqual({ assessmentIds: ['a', 'b'], scanRunIds: ['1', '2'] }); expect(aiOverviewScopeKey('project', scope)).toBe('project::assessments::a|b::scan-runs::1|2'); }); it('rejects invented source IDs and malformed roadmap phases', () => { const result = validAiOverviewResult(); result.roadmap[2].phase = 4; expect(aiOverviewResultValidationErrors(result, new Set(['OTHER']), new Set(['RULE-GAP']))) .toEqual(expect.arrayContaining([expect.stringContaining('unknown source rule ID'), expect.stringContaining('roadmap[2].phase')])); }); it('rejects direct identifiers returned by the model', () => { const result = validAiOverviewResult(); result.executiveSummary = 'Contact admin@example.test at 192.0.2.10.'; expect(aiOverviewResultValidationErrors(result)).toContain('AI overview output contains an email address or IP address.'); }); it('validates the German management brief and still accepts runs saved before it existed', () => { const result = validAiOverviewResult(); result.managementBrief!.roadmap.pop(); result.managementBrief!.priorities[0].timeframe = 'someday' as never; expect(aiOverviewResultValidationErrors(result)).toEqual(expect.arrayContaining([ 'managementBrief.roadmap must contain exactly five phases.', 'managementBrief.priorities[0].timeframe has an unsupported value.' ])); const legacy = validAiOverviewResult(); delete legacy.managementBrief; expect(aiOverviewResultValidationErrors(legacy)).toEqual([]); }); it('rejects a newly generated overview without the management brief', async () => { const legacy = validAiOverviewResult(); delete legacy.managementBrief; await expect(generateAiOverview({ schemaVersion: 1, summary: {}, findings: [], notRunChecks: [], environmentFacts: [] }, { client: { model: 'synthetic', complete: async () => ({ value: legacy }) }, maximumChunkCharacters: 10_000, maximumCalls: 1, findingIds: new Set(['RULE-1']), gapIds: new Set(['RULE-GAP']), factProperties: new Set(['domain.controller_count']), onCall: async () => {} })).rejects.toThrow('managementBrief is required.'); }); it('requires the management brief only from the final consolidation, not from chunk analyses', async () => { const input: PrivacySafeInput = { schemaVersion: 1, summary: { totalResults: 3 }, findings: [1, 2, 3].map(index => ({ id: 'RULE-1', title: String(index).repeat(4000), outcome: 'fail' })), notRunChecks: [], environmentFacts: [] }; const run = (finalHasBrief: boolean) => { const prompts: string[] = []; return generateAiOverview(input, { client: { model: 'synthetic', complete: async (prompt: string) => { prompts.push(prompt); const value = validAiOverviewResult(); const final = prompt.includes('Consolidate the supplied partial analyses'); if (!final || !finalHasBrief) delete value.managementBrief; return { value }; } }, maximumChunkCharacters: 10_000, maximumCalls: 10, findingIds: new Set(['RULE-1']), gapIds: new Set(['RULE-GAP']), factProperties: new Set(['domain.controller_count']), onCall: async () => {} }).then(result => ({ result, prompts })); }; const { result, prompts } = await run(true); expect(prompts.length).toBeGreaterThan(2); expect(result.managementBrief).toBeDefined(); await expect(run(false)).rejects.toThrow('managementBrief is required.'); }); it('offers the management brief only from a current succeeded run', () => { const run = { status: 'succeeded', completedAt: '2026-09-28T08:00:00.000Z', result: validAiOverviewResult() }; const response = (overrides: Partial) => ({ state: 'succeeded', stale: false, run, ...overrides }) as AiOverviewResponse; expect(currentManagementBrief(response({}))).toEqual({ brief: run.result.managementBrief, generatedAt: run.completedAt }); expect(currentManagementBrief(response({ stale: true }))).toBeUndefined(); expect(currentManagementBrief(response({ state: 'running', run: { ...run, status: 'running' } as never }))).toBeUndefined(); expect(currentManagementBrief(response({ run: { ...run, result: { ...run.result, managementBrief: undefined } } as never }))).toBeUndefined(); expect(currentManagementBrief(undefined)).toBeUndefined(); }); it('splits only between complete privacy-safe array entries', () => { const input: PrivacySafeInput = { schemaVersion: 1, summary: { totalResults: 2 }, findings: [ { id: 'RULE-1', title: 'A'.repeat(120), outcome: 'fail' }, { id: 'RULE-2', title: 'B'.repeat(120), outcome: 'fail' } ], notRunChecks: [], environmentFacts: [] }; const chunks = splitPrivacySafeInput(input, 350); expect(chunks).toHaveLength(2); expect(chunks.flatMap(chunk => chunk.findings).map(item => item.id)).toEqual(['RULE-1', 'RULE-2']); expect(chunks.every(chunk => JSON.stringify(chunk).length <= 350)).toBe(true); }); it('gives every chunk and the consolidation call the complete environment facts', async () => { const environmentFacts = [ { title: 'Domain controller count', property: 'domain.controller_count', value: 2, tool: 'pingcastle' }, { title: 'User accounts', property: 'user.account_count', value: 120, tool: 'pingcastle' } ]; const input: PrivacySafeInput = { schemaVersion: 1, summary: { totalResults: 4 }, findings: [1, 2, 3].map(index => ({ id: 'RULE-1', title: String(index).repeat(4000), outcome: 'fail' })), notRunChecks: [], environmentFacts }; const chunks = splitPrivacySafeInput(input, 10_000); expect(chunks.length).toBeGreaterThan(1); expect(chunks.every(chunk => JSON.stringify(chunk.environmentFacts) === JSON.stringify(environmentFacts))).toBe(true); expect(chunks.flatMap(chunk => chunk.findings)).toHaveLength(3); const payloads: Array> = []; await generateAiOverview(input, { client: { model: 'synthetic', complete: async (_prompt: string, value: string) => { payloads.push(JSON.parse(value)); return { value: validAiOverviewResult() }; } }, maximumChunkCharacters: 10_000, maximumCalls: 10, findingIds: new Set(['RULE-1']), gapIds: new Set(['RULE-GAP']), factProperties: new Set(environmentFacts.map(fact => fact.property)), onCall: async () => {} }); expect(payloads.length).toBe(chunks.length + 1); expect(payloads.every(payload => JSON.stringify(payload.environmentFacts) === JSON.stringify(environmentFacts))).toBe(true); expect(payloads.at(-1)).toHaveProperty('partialAnalyses'); }); });