/ src / api / __tests__ / meridian.test.ts
meridian.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 { meridianApi } from '../meridian';
  8  
  9  import queryRefused from '../../__fixtures__/query-refused.json';
 10  import queryOk from '../../__fixtures__/query-ok.json';
 11  import queryOkCalibrated from '../../__fixtures__/query-ok-calibrated.json';
 12  import healthFixture from '../../__fixtures__/health.json';
 13  import ingestFixture from '../../__fixtures__/ingest-success.json';
 14  import snowStatusConfigured from '../../__fixtures__/servicenow-status-configured.json';
 15  import snowStatusUnconfigured from '../../__fixtures__/servicenow-status-unconfigured.json';
 16  import snowIngestFixture from '../../__fixtures__/servicenow-ingest-success.json';
 17  import agentQueryOk from '../../__fixtures__/agent-query-ok.json';
 18  import evalQueriesFixture from '../../__fixtures__/evaluation-queries.json';
 19  import evalMetricsFixture from '../../__fixtures__/evaluation-metrics.json';
 20  import evalUnconfigured from '../../__fixtures__/evaluation-unconfigured.json';
 21  import settingsGetFixture from '../../__fixtures__/settings-get.json';
 22  import settingsPostFixture from '../../__fixtures__/settings-post.json';
 23  
 24  // ── MSW server ──────────────────────────────────────────────────────────────
 25  
 26  let capturedBody: unknown = null;
 27  
 28  const server = setupServer(
 29    http.post('http://localhost:8000/query', async ({ request }) => {
 30      capturedBody = await request.json();
 31      // Default to refused fixture (HTTP 422); individual tests override via server.use()
 32      return HttpResponse.json(queryRefused, { status: 422 });
 33    }),
 34    http.get('http://localhost:8000/health', () => {
 35      return HttpResponse.json(healthFixture);
 36    }),
 37    http.get('http://localhost:8000/ingest/servicenow/status', () => {
 38      return HttpResponse.json(snowStatusConfigured);
 39    }),
 40    http.post('http://localhost:8000/ingest/servicenow', async ({ request }) => {
 41      capturedBody = await request.json();
 42      return HttpResponse.json(snowIngestFixture);
 43    }),
 44    http.post('http://localhost:8000/agent/query', async ({ request }) => {
 45      capturedBody = await request.json();
 46      return HttpResponse.json(agentQueryOk);
 47    }),
 48    http.get('http://localhost:8000/evaluation/queries', () => {
 49      return HttpResponse.json(evalQueriesFixture);
 50    }),
 51    http.get('http://localhost:8000/evaluation/metrics', () => {
 52      return HttpResponse.json(evalMetricsFixture);
 53    }),
 54    http.get('http://localhost:8000/settings', () => {
 55      return HttpResponse.json(settingsGetFixture);
 56    }),
 57    http.post('http://localhost:8000/settings', async ({ request }) => {
 58      capturedBody = await request.json();
 59      return HttpResponse.json(settingsPostFixture);
 60    }),
 61    http.get('http://localhost:8001/health', () => {
 62      return HttpResponse.json({ status: 'ok' });
 63    }),
 64  );
 65  
 66  beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
 67  afterEach(() => {
 68    server.resetHandlers();
 69    capturedBody = null;
 70  });
 71  afterAll(() => server.close());
 72  
 73  // ── Query API tests ─────────────────────────────────────────────────────────
 74  
 75  describe('meridianApi.query', () => {
 76    it('sends the correct payload to /query', async () => {
 77      await meridianApi.query('What is Meridian?');
 78  
 79      expect(capturedBody).toEqual({
 80        question: 'What is Meridian?',
 81      });
 82    });
 83  
 84    it('maps a REFUSED response to QueryResponse', async () => {
 85      const result = await meridianApi.query('What is Meridian?');
 86  
 87      expect(result).toEqual(expect.objectContaining({
 88        status: 'REFUSED',
 89        trace_id: '3d41b36c-dd37-40c7-9394-c70b40b28187',
 90        confidence_score: 0.51432025,
 91        refusal_reason: 'Retrieval confidence below threshold',
 92        threshold: 0.6,
 93      }));
 94    });
 95  
 96    it('maps an OK response to QueryResponse', async () => {
 97      server.use(
 98        http.post('http://localhost:8000/query', async () => {
 99          return HttpResponse.json(queryOk);
100        }),
101      );
102  
103      const result = await meridianApi.query('What is Meridian?');
104  
105      expect(result).toEqual(expect.objectContaining({
106        status: 'OK',
107        trace_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
108        confidence_score: 0.87,
109        raw_confidence: null,
110        answer: 'Meridian is a RAG-powered knowledge engine.',
111        threshold: 0.6,
112      }));
113    });
114  
115    it('maps raw_confidence when calibration is enabled', async () => {
116      server.use(
117        http.post('http://localhost:8000/query', async () => {
118          return HttpResponse.json(queryOkCalibrated);
119        }),
120      );
121  
122      const result = await meridianApi.query('What is calibrated scoring?');
123  
124      expect(result.confidence_score).toBe(0.85);
125      expect(result.raw_confidence).toBe(0.70);
126      expect(result.raw_confidence).not.toBe(result.confidence_score);
127    });
128  
129    it('sends conversation_history when provided', async () => {
130      const history = [
131        { role: 'user' as const, content: 'What topics are covered?' },
132        { role: 'assistant' as const, content: 'Deployments and rollbacks.' },
133      ];
134  
135      await meridianApi.query('Tell me more about #1', history);
136  
137      expect(capturedBody).toEqual({
138        question: 'Tell me more about #1',
139        conversation_history: history,
140      });
141    });
142  
143    it('omits conversation_history when empty', async () => {
144      await meridianApi.query('What is Meridian?', []);
145  
146      expect(capturedBody).toEqual({
147        question: 'What is Meridian?',
148      });
149    });
150  });
151  
152  // ── Health API tests ────────────────────────────────────────────────────────
153  
154  describe('meridianApi.health', () => {
155    it('returns the health response unchanged', async () => {
156      const result = await meridianApi.health();
157  
158      expect(result).toEqual({
159        status: 'ok',
160        document_count: 42,
161        llm_provider: 'azure',
162        retrieval_provider: 'azure',
163        retrieval_threshold: 0.6,
164        suggested_questions: [
165          'What topics are covered in the knowledge base?',
166          'How do I reset my password?',
167          "What is our company's policy on using generative AI tools?",
168          'How do I get access to Azure OpenAI for my project?',
169        ],
170      });
171    });
172  });
173  
174  // ── Ingest API tests ─────────────────────────────────────────────────────────
175  
176  describe('meridianApi.ingest', () => {
177    it('sends FormData to /ingest and returns ingestion result', async () => {
178      server.use(
179        http.post('http://localhost:8000/ingest', async () => {
180          return HttpResponse.json(ingestFixture);
181        }),
182      );
183  
184      const formData = new FormData();
185      formData.append('files', new Blob(['hello'], { type: 'text/plain' }), 'test.txt');
186  
187      const result = await meridianApi.ingest(formData);
188  
189      expect(result).toEqual({
190        ingested: 1,
191        chunks: 5,
192        message: '1 documents ingested (5 chunks)',
193      });
194    });
195  });
196  
197  // ── ServiceNow Status API tests ─────────────────────────────────────────────
198  
199  describe('meridianApi.serviceNowStatus', () => {
200    it('returns configured status with last sync info', async () => {
201      const result = await meridianApi.serviceNowStatus();
202  
203      expect(result).toEqual(snowStatusConfigured);
204      expect(result.configured).toBe(true);
205      expect(result.last_sync).not.toBeNull();
206      expect(result.last_sync?.ingested).toBe(15);
207    });
208  
209    it('returns unconfigured status when credentials are missing', async () => {
210      server.use(
211        http.get('http://localhost:8000/ingest/servicenow/status', () => {
212          return HttpResponse.json(snowStatusUnconfigured);
213        }),
214      );
215  
216      const result = await meridianApi.serviceNowStatus();
217  
218      expect(result.configured).toBe(false);
219      expect(result.last_sync).toBeNull();
220      expect(result.history).toEqual([]);
221    });
222  });
223  
224  // ── ServiceNow Ingest API tests ─────────────────────────────────────────────
225  
226  describe('meridianApi.ingestServiceNow', () => {
227    it('sends filters to /ingest/servicenow and returns ingestion result', async () => {
228      const result = await meridianApi.ingestServiceNow({
229        kb_name: 'IT Knowledge Base',
230        category: 'Networking',
231        limit: 50,
232      });
233  
234      expect(capturedBody).toEqual({
235        kb_name: 'IT Knowledge Base',
236        category: 'Networking',
237        limit: 50,
238      });
239      expect(result).toEqual({
240        ingested: 15,
241        chunks: 87,
242        message: '15 ServiceNow articles ingested (87 chunks)',
243      });
244    });
245  
246    it('sends empty object when no filters are provided', async () => {
247      await meridianApi.ingestServiceNow({});
248  
249      expect(capturedBody).toEqual({});
250    });
251  
252    it('throws ApiError with 502 when ServiceNow is unreachable', async () => {
253      server.use(
254        http.post('http://localhost:8000/ingest/servicenow', () => {
255          return HttpResponse.json(
256            { detail: 'Failed to connect to ServiceNow instance' },
257            { status: 502 },
258          );
259        }),
260      );
261  
262      await expect(
263        meridianApi.ingestServiceNow({ kb_name: 'IT KB' }),
264      ).rejects.toThrow('Failed to connect to ServiceNow instance');
265    });
266  
267    it('throws ApiError with 400 when credentials are missing', async () => {
268      server.use(
269        http.post('http://localhost:8000/ingest/servicenow', () => {
270          return HttpResponse.json(
271            { detail: 'ServiceNow credentials required.' },
272            { status: 400 },
273          );
274        }),
275      );
276  
277      await expect(
278        meridianApi.ingestServiceNow({}),
279      ).rejects.toThrow('ServiceNow credentials required.');
280    });
281  });
282  
283  // ── Agent Query API tests ──────────────────────────────────────────────────
284  
285  describe('meridianApi.agentQuery', () => {
286    it('sends the question to /agent/query and returns the response', async () => {
287      const result = await meridianApi.agentQuery('Why are login requests failing for region us-east?');
288  
289      expect(capturedBody).toEqual({
290        question: 'Why are login requests failing for region us-east?',
291      });
292      expect(result).toEqual(agentQueryOk);
293    });
294  
295    it('maps response fields correctly', async () => {
296      const result = await meridianApi.agentQuery('test');
297  
298      expect(result.status).toBe('OK');
299      expect(result.trace_id).toBe('agt-7f3a2e01-b9c4-4d8f-a1e2-c3d4e5f6a7b8');
300      expect(result.steps_taken).toBe(4);
301      expect(result.elapsed_ms).toBe(27000);
302      expect(result.steps).toHaveLength(4);
303      expect(result.steps[0]).toEqual(expect.objectContaining({
304        step: 1,
305        tool: 'search_incidents',
306        elapsed_ms: 460,
307      }));
308    });
309  
310    it('throws ApiError on 500 server error', async () => {
311      server.use(
312        http.post('http://localhost:8000/agent/query', () => {
313          return HttpResponse.json(
314            { detail: 'Agent execution failed: tool timeout' },
315            { status: 500 },
316          );
317        }),
318      );
319  
320      await expect(
321        meridianApi.agentQuery('test'),
322      ).rejects.toThrow('Agent execution failed: tool timeout');
323    });
324  });
325  
326  // ── Evaluation Queries API tests ────────────────────────────────────────────
327  
328  describe('meridianApi.evaluationQueries', () => {
329    it('returns paginated query log entries', async () => {
330      const result = await meridianApi.evaluationQueries();
331  
332      expect(result.configured).toBe(true);
333      expect(result.total).toBe(3);
334      expect(result.queries).toHaveLength(3);
335    });
336  
337    it('maps query entry fields correctly', async () => {
338      const result = await meridianApi.evaluationQueries();
339      const first = result.queries![0];
340  
341      expect(first).toEqual(expect.objectContaining({
342        id: 'q-001',
343        trace_id: 'eval-aaa-1111',
344        status: 'OK',
345        confidence: 0.82,
346        raw_confidence: 0.71,
347        source: 'query',
348      }));
349    });
350  
351    it('includes raw_confidence for calibrated entries', async () => {
352      const result = await meridianApi.evaluationQueries();
353      const calibrated = result.queries!.find((q) => q.id === 'q-001')!;
354  
355      expect(calibrated.raw_confidence).toBe(0.71);
356      expect(calibrated.confidence).toBe(0.82);
357      expect(calibrated.raw_confidence).not.toBe(calibrated.confidence);
358    });
359  
360    it('returns unconfigured when database is not set up', async () => {
361      server.use(
362        http.get('http://localhost:8000/evaluation/queries', () => {
363          return HttpResponse.json(evalUnconfigured);
364        }),
365      );
366  
367      const result = await meridianApi.evaluationQueries();
368  
369      expect(result.configured).toBe(false);
370      expect(result.error).toBe('Database not configured');
371    });
372  });
373  
374  // ── Evaluation Metrics API tests ────────────────────────────────────────────
375  
376  describe('meridianApi.evaluationMetrics', () => {
377    it('returns aggregate metrics', async () => {
378      const result = await meridianApi.evaluationMetrics();
379  
380      expect(result.configured).toBe(true);
381      expect(result.total_queries).toBe(127);
382      expect(result.avg_confidence).toBe(0.7234);
383      expect(result.refusal_rate).toBe(0.1575);
384    });
385  
386    it('includes latency percentiles', async () => {
387      const result = await meridianApi.evaluationMetrics();
388  
389      expect(result.latency_p50_ms).toBe(980);
390      expect(result.latency_p95_ms).toBe(2450);
391    });
392  
393    it('includes status and source breakdowns', async () => {
394      const result = await meridianApi.evaluationMetrics();
395  
396      expect(result.queries_by_status).toEqual({ OK: 107, REFUSED: 20 });
397      expect(result.queries_by_source).toEqual({ query: 98, agent: 29 });
398    });
399  
400    it('returns unconfigured when database is not set up', async () => {
401      server.use(
402        http.get('http://localhost:8000/evaluation/metrics', () => {
403          return HttpResponse.json(evalUnconfigured);
404        }),
405      );
406  
407      const result = await meridianApi.evaluationMetrics();
408  
409      expect(result.configured).toBe(false);
410    });
411  });
412  
413  // ── Settings API tests ────────────────────────────────────────────────────
414  
415  describe('meridianApi.getSettings', () => {
416    it('returns current runtime configuration', async () => {
417      const result = await meridianApi.getSettings();
418  
419      expect(result).toEqual({
420        llm_provider: 'azure',
421        retrieval_provider: 'azure',
422        retrieval_threshold: 0.6,
423        temperature: 0.7,
424      });
425    });
426  
427    it('maps all settings fields including temperature', async () => {
428      const result = await meridianApi.getSettings();
429  
430      expect(result).toHaveProperty('llm_provider');
431      expect(result).toHaveProperty('retrieval_provider');
432      expect(result).toHaveProperty('retrieval_threshold');
433      expect(result).toHaveProperty('temperature');
434      expect(typeof result.retrieval_threshold).toBe('number');
435      expect(typeof result.temperature).toBe('number');
436    });
437  });
438  
439  // ── MCP Health API tests ──────────────────────────────────────────────────
440  
441  describe('meridianApi.mcpHealth', () => {
442    it('returns reachable true when MCP server responds 200', async () => {
443      const result = await meridianApi.mcpHealth();
444  
445      expect(result).toEqual({ reachable: true });
446    });
447  
448    it('returns reachable false when MCP server returns non-200', async () => {
449      server.use(
450        http.get('http://localhost:8001/health', () => {
451          return HttpResponse.json({ status: 'error' }, { status: 503 });
452        }),
453      );
454  
455      const result = await meridianApi.mcpHealth();
456  
457      expect(result).toEqual({ reachable: false });
458    });
459  
460    it('returns reachable false when MCP server is unreachable', async () => {
461      server.use(
462        http.get('http://localhost:8001/health', () => {
463          return HttpResponse.error();
464        }),
465      );
466  
467      const result = await meridianApi.mcpHealth();
468  
469      expect(result).toEqual({ reachable: false });
470    });
471  });
472  
473  describe('meridianApi.updateSettings', () => {
474    it('sends the payload to POST /settings and returns updated config', async () => {
475      const payload = {
476        llm_provider: 'local' as const,
477        retrieval_provider: 'chroma' as const,
478        retrieval_threshold: 0.75,
479        temperature: 0.3,
480      };
481  
482      const result = await meridianApi.updateSettings(payload);
483  
484      expect(capturedBody).toEqual(payload);
485      expect(result).toEqual({
486        llm_provider: 'local',
487        retrieval_provider: 'chroma',
488        retrieval_threshold: 0.75,
489        temperature: 0.3,
490      });
491    });
492  });