/ src / api / __tests__ / investigation.test.ts
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  });