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 });