|
| 1 | +import test from "node:test"; |
| 2 | +import assert from "node:assert/strict"; |
| 3 | +import fs from "node:fs"; |
| 4 | +import os from "node:os"; |
| 5 | +import path from "node:path"; |
| 6 | + |
| 7 | +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-call-logs-stats-")); |
| 8 | +process.env.DATA_DIR = TEST_DATA_DIR; |
| 9 | +process.env.NODE_ENV = "test"; |
| 10 | +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; |
| 11 | + |
| 12 | +const core = await import("../../../src/lib/db/core.ts"); |
| 13 | +const stats = await import("../../../src/lib/db/callLogStats.ts"); |
| 14 | + |
| 15 | +function resetDb() { |
| 16 | + core.resetDbInstance(); |
| 17 | + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); |
| 18 | + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); |
| 19 | +} |
| 20 | + |
| 21 | +test.beforeEach(() => { |
| 22 | + resetDb(); |
| 23 | +}); |
| 24 | + |
| 25 | +test.after(() => { |
| 26 | + core.resetDbInstance(); |
| 27 | + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); |
| 28 | +}); |
| 29 | + |
| 30 | +test("getFallbackStats on empty DB returns zeros, not nulls", () => { |
| 31 | + core.getDbInstance(); // plays SCHEMA + runMigrations on the file DB |
| 32 | + const row = stats.getFallbackStats("", {}); |
| 33 | + assert.deepEqual(row, { |
| 34 | + total: 0, |
| 35 | + with_requested: 0, |
| 36 | + fallback_eligible: 0, |
| 37 | + fallbacks: 0, |
| 38 | + }); |
| 39 | +}); |
| 40 | + |
| 41 | +test("avgLatencyMs is null when all durations are NULL, and the route propagates null", async () => { |
| 42 | + const db = core.getDbInstance(); |
| 43 | + const now = new Date().toISOString(); |
| 44 | + db.prepare( |
| 45 | + `INSERT INTO provider_connections (id, provider, created_at, updated_at) |
| 46 | + VALUES ('conn-1', 'openai', ?, ?)` |
| 47 | + ).run(now, now); |
| 48 | + db.prepare( |
| 49 | + `INSERT INTO call_logs (id, timestamp, provider, status, duration) |
| 50 | + VALUES ('log-1', ?, 'openai', 200, NULL)` |
| 51 | + ).run(now); |
| 52 | + |
| 53 | + const { toNumberOrNull } = await import("../../../src/shared/utils/numeric.ts"); |
| 54 | + const rows = stats.getProviderMetrics(); |
| 55 | + assert.equal(rows.length, 1); |
| 56 | + // Lib-level: passes before AND after (the driver already returns null — only |
| 57 | + // the TS type lied). Kept as documentation, not as red/green proof. |
| 58 | + assert.equal(toNumberOrNull(rows[0].avgLatencyMs), null); |
| 59 | + |
| 60 | + const { GET } = await import("../../../src/app/api/provider-metrics/route.ts"); |
| 61 | + const res = await GET(); |
| 62 | + const body = (await res.json()) as { |
| 63 | + metrics: Record<string, { avgLatencyMs: number | null }>; |
| 64 | + }; |
| 65 | + // Route-level: THIS is the red/green proof (toNumber(null) → 0 before fix). |
| 66 | + assert.equal(body.metrics["openai"].avgLatencyMs, null); |
| 67 | +}); |
| 68 | + |
| 69 | +test("error_type NULL splits into pre_migration vs unclassified by timestamp", () => { |
| 70 | + const db = core.getDbInstance(); |
| 71 | + const now = new Date().toISOString(); |
| 72 | + db.prepare( |
| 73 | + `INSERT INTO provider_connections (id, provider, created_at, updated_at) |
| 74 | + VALUES ('conn-1', 'openai', ?, ?)` |
| 75 | + ).run(now, now); |
| 76 | + db.prepare( |
| 77 | + `INSERT INTO call_logs (id, timestamp, provider, status, error_type) |
| 78 | + VALUES ('old-1', '2026-08-01T00:00:00.000Z', 'openai', 500, NULL), |
| 79 | + ('new-1', ?, 'openai', 500, NULL)` |
| 80 | + ).run(now); |
| 81 | + |
| 82 | + const breakdown = stats.getErrorTypeBreakdown("", {}); |
| 83 | + const byType = new Map(breakdown.map((b) => [b.errorType, b.count])); |
| 84 | + assert.equal(byType.get("pre_migration"), 1); |
| 85 | + assert.equal(byType.get("unclassified"), 1); |
| 86 | +}); |
| 87 | + |
| 88 | +test("migration 174 creates provider GROUP BY indexes used by search stats", () => { |
| 89 | + const db = core.getDbInstance(); |
| 90 | + |
| 91 | + const names = ( |
| 92 | + db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all() as Array<{ |
| 93 | + name: string; |
| 94 | + }> |
| 95 | + ).map((r) => r.name); |
| 96 | + assert.ok( |
| 97 | + names.includes("idx_cl_provider_timestamp"), |
| 98 | + "idx_cl_provider_timestamp must exist after migrations" |
| 99 | + ); |
| 100 | + assert.ok( |
| 101 | + names.includes("idx_cl_request_provider"), |
| 102 | + "idx_cl_request_provider must exist after migrations" |
| 103 | + ); |
| 104 | + |
| 105 | + const plan = ( |
| 106 | + db |
| 107 | + .prepare( |
| 108 | + "EXPLAIN QUERY PLAN SELECT provider, COUNT(*), AVG(duration) FROM call_logs WHERE request_type = 'search' GROUP BY provider" |
| 109 | + ) |
| 110 | + .all() as Array<{ detail: string }> |
| 111 | + ) |
| 112 | + .map((r) => r.detail) |
| 113 | + .join(" | "); |
| 114 | + assert.ok( |
| 115 | + plan.includes("USING INDEX idx_cl_request_provider"), |
| 116 | + `planner must use idx_cl_request_provider, got: ${plan}` |
| 117 | + ); |
| 118 | + assert.ok(!plan.includes("SCAN TABLE"), `must not table-scan, got: ${plan}`); |
| 119 | +}); |
0 commit comments