investigation.test.ts
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 4 import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; 5 import { http, HttpResponse } from 'msw'; 6 import { setupServer } from 'msw/node'; 7 import { investigationApi, TERMINAL_STATES } from '../investigation'; 8 import type { InvestigationStatus } from '../investigation'; 9 10 import listFixture from '../../__fixtures__/investigation-list.json'; 11 import detailFixture from '../../__fixtures__/investigation-detail.json'; 12 13 const server = setupServer( 14 http.get('http://localhost:8000/ops/investigations', ({ request }) => { 15 const url = new URL(request.url); 16 const status = url.searchParams.get('status'); 17 if (status) { 18 const filtered = (listFixture as typeof listFixture).filter((i) => i.status === status); 19 return HttpResponse.json(filtered); 20 } 21 return HttpResponse.json(listFixture); 22 }), 23 http.get('http://localhost:8000/ops/investigations/pending', () => 24 HttpResponse.json(listFixture.filter((i) => i.status === 'AWAITING_APPROVAL')), 25 ), 26 http.get('http://localhost:8000/ops/investigations/OPS-1234-inv-20260320-143200', () => 27 HttpResponse.json(detailFixture), 28 ), 29 http.post('http://localhost:8000/ops/approve', () => 30 HttpResponse.json({ trace_id: 'OPS-1234-inv-20260320-143200', status: 'APPROVED', approval_ref: 'studio-ref-123' }), 31 ), 32 http.post('http://localhost:8000/ops/reject', () => 33 HttpResponse.json({ trace_id: 'OPS-1234-inv-20260320-143200', status: 'REJECTED' }), 34 ), 35 ); 36 37 beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 38 afterEach(() => server.resetHandlers()); 39 afterAll(() => server.close()); 40 41 describe('investigationApi', () => { 42 it('list() returns array of InvestigationSummary', async () => { 43 const result = await investigationApi.list(); 44 expect(result).toHaveLength(3); 45 expect(result[0].jira_key).toBe('OPS-1234'); 46 expect(result[0].title).toBe('Payments ETL mismatch in settlement totals'); 47 expect(result[0].confidence).toBe(0.87); 48 expect(result[0].is_terminal).toBe(false); 49 }); 50 51 it('list() passes status filter', async () => { 52 const result = await investigationApi.list('AWAITING_APPROVAL'); 53 expect(result).toHaveLength(1); 54 expect(result[0].status).toBe('AWAITING_APPROVAL'); 55 }); 56 57 it('get() returns InvestigationDetail with safe DTO fields', async () => { 58 const result = await investigationApi.get('OPS-1234-inv-20260320-143200'); 59 expect(result.trace_id).toBe('OPS-1234-inv-20260320-143200'); 60 expect(result.title).toBe('Payments ETL mismatch in settlement totals'); 61 expect(result.description).toBeTruthy(); 62 expect(result.confidence).toBe(0.87); 63 expect(result.root_cause_hypothesis).toBeTruthy(); 64 expect(result.severity).toBe('high'); 65 expect(result.findings).toHaveLength(3); 66 expect(result.steps).toHaveLength(11); 67 expect(result.step_count).toBe(11); 68 }); 69 70 it('get() returns execution plan summary (no raw parameters)', async () => { 71 const result = await investigationApi.get('OPS-1234-inv-20260320-143200'); 72 const plan = result.execution_plan!; 73 expect(plan.plan_id).toBe('PLAN-OPS-1234-001'); 74 expect(plan.blast_radius).toBe('medium'); 75 expect(plan.total_steps).toBe(2); 76 expect(plan.reversible).toBe(true); 77 // No raw parameters, preconditions, or rollback commands in the summary DTO 78 expect((plan as unknown as Record<string, unknown>)['steps']).toBeUndefined(); 79 }); 80 81 it('get() returns finding summaries (no tool queries or hashes)', async () => { 82 const result = await investigationApi.get('OPS-1234-inv-20260320-143200'); 83 const finding = result.findings[0]; 84 expect(finding.id).toBe('F-001'); 85 expect(finding.source).toBe('postgres_payments'); 86 expect(finding.summary).toBeTruthy(); 87 // No tool_used, query, raw_result_hash in the safe DTO 88 expect((finding as unknown as Record<string, unknown>)['tool_used']).toBeUndefined(); 89 expect((finding as unknown as Record<string, unknown>)['query']).toBeUndefined(); 90 }); 91 92 it('pendingList() returns InvestigationSummary[]', async () => { 93 const result = await investigationApi.pendingList(); 94 expect(result).toHaveLength(1); 95 expect(result[0].status).toBe('AWAITING_APPROVAL'); 96 expect(result[0].jira_key).toBe('OPS-1234'); 97 }); 98 99 it('approve() sends trace_id and approval_ref', async () => { 100 const result = await investigationApi.approve('OPS-1234-inv-20260320-143200', 'studio-ref-123', 'PLAN-001'); 101 expect(result.trace_id).toBe('OPS-1234-inv-20260320-143200'); 102 expect(result.status).toBe('APPROVED'); 103 }); 104 105 it('reject() sends trace_id and reason', async () => { 106 const result = await investigationApi.reject('OPS-1234-inv-20260320-143200', 'Needs review'); 107 expect(result.trace_id).toBe('OPS-1234-inv-20260320-143200'); 108 expect(result.status).toBe('REJECTED'); 109 }); 110 }); 111 112 describe('TERMINAL_STATES', () => { 113 it('contains all terminal statuses', () => { 114 const expected: InvestigationStatus[] = [ 115 'NO_ACTION_REQUIRED', 'INSUFFICIENT_DATA', 'REJECTED', 'EXPIRED', 'COMPLETED', 'FAILED', 116 ]; 117 expected.forEach((s) => expect(TERMINAL_STATES.has(s)).toBe(true)); 118 }); 119 120 it('does not contain active statuses', () => { 121 const active: InvestigationStatus[] = [ 122 'INTAKE', 'RESEARCH', 'DATA_ANALYSIS', 'POLICY_EVALUATE', 'AWAITING_APPROVAL', 'APPROVED', 'EXECUTING', 123 ]; 124 active.forEach((s) => expect(TERMINAL_STATES.has(s)).toBe(false)); 125 }); 126 });