llm-usage-tracker.test.js
1 /** 2 * Tests for LLM Usage Tracker Module 3 * 4 * Tests calculateCost, logLLMUsage, getSiteCost, and getCostByStage functions 5 * using a temporary SQLite database. 6 */ 7 8 import { describe, test, before, after, beforeEach } from 'node:test'; 9 import assert from 'node:assert/strict'; 10 import { tmpdir } from 'os'; 11 import { join } from 'path'; 12 import { readFileSync, unlinkSync, existsSync } from 'fs'; 13 import Database from 'better-sqlite3'; 14 import { setTimeout as sleep } from 'timers/promises'; 15 16 // Set up temp database BEFORE importing the module under test 17 const testDb = join(tmpdir(), `test-llm-usage-${Date.now()}.db`); 18 process.env.DATABASE_PATH = testDb; 19 20 // Dynamic import after setting DATABASE_PATH 21 const { 22 calculateCost, 23 logLLMUsage, 24 getSiteCost, 25 getCostByStage, 26 checkBudgetVariance, 27 getDailySpend, 28 getHourlySpend, 29 } = await import('../../src/utils/llm-usage-tracker.js'); 30 31 const schemaPath = join(import.meta.dirname, '..', '..', 'db', 'schema.sql'); 32 const schemaSql = readFileSync(schemaPath, 'utf-8'); 33 34 /** 35 * Initialize the test database with the full schema 36 */ 37 function initSchema() { 38 const db = new Database(testDb); 39 db.exec(schemaSql); 40 db.close(); 41 } 42 43 /** 44 * Clear all rows from llm_usage and sites tables 45 */ 46 function clearTables() { 47 const db = new Database(testDb); 48 db.exec('DELETE FROM llm_usage'); 49 db.exec('DELETE FROM sites'); 50 db.close(); 51 } 52 53 /** 54 * Insert a test site and return its id 55 */ 56 function insertTestSite(domain = 'example.com') { 57 const db = new Database(testDb); 58 const result = db 59 .prepare( 60 `INSERT INTO sites (domain, landing_page_url, keyword, status) 61 VALUES (?, ?, ?, 'found')` 62 ) 63 .run(domain, `https://${domain}`, 'test keyword'); 64 db.close(); 65 return result.lastInsertRowid; 66 } 67 68 /** 69 * Query all rows from llm_usage table 70 */ 71 function getAllUsageRows() { 72 const db = new Database(testDb); 73 const rows = db.prepare('SELECT * FROM llm_usage ORDER BY id').all(); 74 db.close(); 75 return rows; 76 } 77 78 describe('LLM Usage Tracker', () => { 79 before(() => { 80 initSchema(); 81 }); 82 83 beforeEach(() => { 84 clearTables(); 85 }); 86 87 after(() => { 88 if (existsSync(testDb)) { 89 unlinkSync(testDb); 90 } 91 }); 92 93 describe('calculateCost', () => { 94 test('returns correct cost for openai/gpt-4o-mini', () => { 95 // Input: $0.15 per 1M tokens, Output: $0.60 per 1M tokens 96 const promptTokens = 1_000_000; 97 const completionTokens = 1_000_000; 98 const cost = calculateCost('openai/gpt-4o-mini', promptTokens, completionTokens); 99 100 // 1M * $0.15/1M + 1M * $0.60/1M = $0.75 101 assert.equal(cost, 0.75); 102 }); 103 104 test('returns correct cost for openai/gpt-4o-mini with smaller token counts', () => { 105 // 500 input tokens, 200 output tokens 106 const cost = calculateCost('openai/gpt-4o-mini', 500, 200); 107 108 // (500 / 1M) * 0.15 + (200 / 1M) * 0.60 109 // = 0.000075 + 0.000120 110 // = 0.000195 111 const expected = parseFloat((0.000075 + 0.00012).toFixed(6)); 112 assert.equal(cost, expected); 113 }); 114 115 test('returns correct cost for claude-3-5-sonnet-20241022', () => { 116 // Input: $3.00 per 1M tokens, Output: $15.00 per 1M tokens 117 const promptTokens = 1000; 118 const completionTokens = 500; 119 const cost = calculateCost('claude-3-5-sonnet-20241022', promptTokens, completionTokens); 120 121 // (1000 / 1M) * 3.0 + (500 / 1M) * 15.0 122 // = 0.003 + 0.0075 123 // = 0.0105 124 const expected = parseFloat((0.003 + 0.0075).toFixed(6)); 125 assert.equal(cost, expected); 126 }); 127 128 test('uses default pricing for unknown model', () => { 129 const promptTokens = 1_000_000; 130 const completionTokens = 1_000_000; 131 const cost = calculateCost('unknown/model-xyz', promptTokens, completionTokens); 132 133 // Default: input $0.50/1M, output $1.50/1M 134 // (1M * 0.5 + 1M * 1.5) / 1M = 2.0 135 const expected = ((promptTokens * 0.5 + completionTokens * 1.5) / 1_000_000).toFixed(6); 136 assert.equal(cost, expected); 137 }); 138 139 test('returns string for unknown model (toFixed returns string)', () => { 140 const cost = calculateCost('unknown/model', 100, 50); 141 // Unknown model path returns result of toFixed(6), which is a string 142 assert.equal(typeof cost, 'string'); 143 }); 144 145 test('returns 0 for zero tokens', () => { 146 const cost = calculateCost('openai/gpt-4o-mini', 0, 0); 147 assert.equal(cost, 0); 148 }); 149 150 test('returns 0 for zero tokens with claude model', () => { 151 const cost = calculateCost('claude-3-5-sonnet-20241022', 0, 0); 152 assert.equal(cost, 0); 153 }); 154 }); 155 156 describe('logLLMUsage', () => { 157 test('inserts a record into llm_usage table', () => { 158 const siteId = insertTestSite('test-log.com'); 159 160 logLLMUsage({ 161 siteId, 162 stage: 'scoring', 163 provider: 'openrouter', 164 model: 'openai/gpt-4o-mini', 165 promptTokens: 1000, 166 completionTokens: 500, 167 requestId: 'req-123', 168 }); 169 170 const rows = getAllUsageRows(); 171 assert.equal(rows.length, 1); 172 assert.equal(rows[0].site_id, siteId); 173 assert.equal(rows[0].stage, 'scoring'); 174 assert.equal(rows[0].provider, 'openrouter'); 175 assert.equal(rows[0].model, 'openai/gpt-4o-mini'); 176 assert.equal(rows[0].prompt_tokens, 1000); 177 assert.equal(rows[0].completion_tokens, 500); 178 assert.equal(rows[0].total_tokens, 1500); 179 assert.equal(rows[0].request_id, 'req-123'); 180 }); 181 182 test('handles null siteId', () => { 183 logLLMUsage({ 184 siteId: null, 185 stage: 'other', 186 provider: 'anthropic', 187 model: 'claude-3-5-sonnet-20241022', 188 promptTokens: 200, 189 completionTokens: 100, 190 }); 191 192 const rows = getAllUsageRows(); 193 assert.equal(rows.length, 1); 194 assert.equal(rows[0].site_id, null); 195 assert.equal(rows[0].stage, 'other'); 196 }); 197 198 test('handles omitted siteId (defaults to null)', () => { 199 logLLMUsage({ 200 stage: 'proposals', 201 provider: 'openrouter', 202 model: 'openai/gpt-4o', 203 promptTokens: 300, 204 completionTokens: 150, 205 }); 206 207 const rows = getAllUsageRows(); 208 assert.equal(rows.length, 1); 209 assert.equal(rows[0].site_id, null); 210 }); 211 212 test('calculates cost correctly and stores it', () => { 213 const siteId = insertTestSite('cost-calc.com'); 214 215 logLLMUsage({ 216 siteId, 217 stage: 'scoring', 218 provider: 'openrouter', 219 model: 'openai/gpt-4o-mini', 220 promptTokens: 1_000_000, 221 completionTokens: 1_000_000, 222 }); 223 224 const rows = getAllUsageRows(); 225 assert.equal(rows.length, 1); 226 // 1M * $0.15/1M + 1M * $0.60/1M = $0.75 227 assert.equal(rows[0].estimated_cost, 0.75); 228 }); 229 230 test('stores total_tokens as sum of prompt and completion', () => { 231 logLLMUsage({ 232 stage: 'enrichment', 233 provider: 'anthropic', 234 model: 'claude-3-5-haiku-20241022', 235 promptTokens: 750, 236 completionTokens: 250, 237 }); 238 239 const rows = getAllUsageRows(); 240 assert.equal(rows[0].total_tokens, 1000); 241 }); 242 243 test('handles null requestId by default', () => { 244 logLLMUsage({ 245 stage: 'rescoring', 246 provider: 'openrouter', 247 model: 'openai/gpt-4o', 248 promptTokens: 100, 249 completionTokens: 50, 250 }); 251 252 const rows = getAllUsageRows(); 253 assert.equal(rows[0].request_id, null); 254 }); 255 }); 256 257 describe('getSiteCost', () => { 258 test('returns 0 for site with no usage', () => { 259 const siteId = insertTestSite('no-usage.com'); 260 const cost = getSiteCost(siteId); 261 assert.equal(cost, 0); 262 }); 263 264 test('returns 0 for non-existent site ID', () => { 265 const cost = getSiteCost(99999); 266 assert.equal(cost, 0); 267 }); 268 269 test('returns correct total cost for single usage entry', () => { 270 const siteId = insertTestSite('single-usage.com'); 271 272 logLLMUsage({ 273 siteId, 274 stage: 'scoring', 275 provider: 'openrouter', 276 model: 'openai/gpt-4o-mini', 277 promptTokens: 1_000_000, 278 completionTokens: 1_000_000, 279 }); 280 281 const cost = getSiteCost(siteId); 282 // $0.15 + $0.60 = $0.75 283 assert.equal(cost, 0.75); 284 }); 285 286 test('returns correct total cost summing multiple usage entries', () => { 287 const siteId = insertTestSite('multi-usage.com'); 288 289 // First usage: scoring with gpt-4o-mini 290 logLLMUsage({ 291 siteId, 292 stage: 'scoring', 293 provider: 'openrouter', 294 model: 'openai/gpt-4o-mini', 295 promptTokens: 1_000_000, 296 completionTokens: 1_000_000, 297 }); 298 299 // Second usage: rescoring with gpt-4o-mini 300 logLLMUsage({ 301 siteId, 302 stage: 'rescoring', 303 provider: 'openrouter', 304 model: 'openai/gpt-4o-mini', 305 promptTokens: 1_000_000, 306 completionTokens: 1_000_000, 307 }); 308 309 const cost = getSiteCost(siteId); 310 // $0.75 + $0.75 = $1.50 311 assert.equal(cost, 1.5); 312 }); 313 314 test('does not include costs from other sites', () => { 315 const siteId1 = insertTestSite('site-one.com'); 316 const siteId2 = insertTestSite('site-two.com'); 317 318 logLLMUsage({ 319 siteId: siteId1, 320 stage: 'scoring', 321 provider: 'openrouter', 322 model: 'openai/gpt-4o-mini', 323 promptTokens: 1_000_000, 324 completionTokens: 1_000_000, 325 }); 326 327 logLLMUsage({ 328 siteId: siteId2, 329 stage: 'scoring', 330 provider: 'openrouter', 331 model: 'openai/gpt-4o', 332 promptTokens: 1_000_000, 333 completionTokens: 1_000_000, 334 }); 335 336 const cost1 = getSiteCost(siteId1); 337 const cost2 = getSiteCost(siteId2); 338 339 // site1: gpt-4o-mini = $0.75 340 assert.equal(cost1, 0.75); 341 // site2: gpt-4o = $2.50 + $10.00 = $12.50 342 assert.equal(cost2, 12.5); 343 }); 344 }); 345 346 describe('getCostByStage', () => { 347 test('returns empty array when no usage exists', () => { 348 const result = getCostByStage(); 349 assert.deepEqual(result, []); 350 }); 351 352 test('groups costs by stage correctly', () => { 353 const siteId = insertTestSite('stage-grouping.com'); 354 355 // Two scoring entries 356 logLLMUsage({ 357 siteId, 358 stage: 'scoring', 359 provider: 'openrouter', 360 model: 'openai/gpt-4o-mini', 361 promptTokens: 1_000_000, 362 completionTokens: 1_000_000, 363 }); 364 365 logLLMUsage({ 366 siteId, 367 stage: 'scoring', 368 provider: 'openrouter', 369 model: 'openai/gpt-4o-mini', 370 promptTokens: 1_000_000, 371 completionTokens: 1_000_000, 372 }); 373 374 // One proposals entry 375 logLLMUsage({ 376 siteId, 377 stage: 'proposals', 378 provider: 'anthropic', 379 model: 'claude-3-5-sonnet-20241022', 380 promptTokens: 1000, 381 completionTokens: 500, 382 }); 383 384 const result = getCostByStage(); 385 386 assert.equal(result.length, 2); 387 388 // Find each stage in results 389 const scoringRow = result.find(r => r.stage === 'scoring'); 390 const proposalsRow = result.find(r => r.stage === 'proposals'); 391 392 assert.ok(scoringRow, 'scoring stage should exist'); 393 assert.ok(proposalsRow, 'proposals stage should exist'); 394 395 // Scoring: 2 * $0.75 = $1.50 396 assert.equal(scoringRow.total_cost, 1.5); 397 assert.equal(scoringRow.request_count, 2); 398 assert.equal(scoringRow.total_tokens, 4_000_000); 399 400 // Proposals: (1000/1M) * 3.0 + (500/1M) * 15.0 = 0.0105 401 assert.equal(proposalsRow.request_count, 1); 402 assert.equal(proposalsRow.total_tokens, 1500); 403 }); 404 405 test('orders results by total_cost DESC', () => { 406 const siteId = insertTestSite('order-test.com'); 407 408 // Low cost entry (enrichment) 409 logLLMUsage({ 410 siteId, 411 stage: 'enrichment', 412 provider: 'openrouter', 413 model: 'openai/gpt-4o-mini', 414 promptTokens: 100, 415 completionTokens: 50, 416 }); 417 418 // High cost entry (proposals with expensive model) 419 logLLMUsage({ 420 siteId, 421 stage: 'proposals', 422 provider: 'anthropic', 423 model: 'claude-3-opus-20240229', 424 promptTokens: 1_000_000, 425 completionTokens: 1_000_000, 426 }); 427 428 // Medium cost entry (scoring) 429 logLLMUsage({ 430 siteId, 431 stage: 'scoring', 432 provider: 'openrouter', 433 model: 'openai/gpt-4o', 434 promptTokens: 1_000_000, 435 completionTokens: 1_000_000, 436 }); 437 438 const result = getCostByStage(); 439 440 assert.equal(result.length, 3); 441 // Opus: $15 + $75 = $90 (highest) 442 assert.equal(result[0].stage, 'proposals'); 443 // GPT-4o: $2.50 + $10 = $12.50 (medium) 444 assert.equal(result[1].stage, 'scoring'); 445 // GPT-4o-mini: tiny cost (lowest) 446 assert.equal(result[2].stage, 'enrichment'); 447 }); 448 449 test('returns correct request_count per stage', () => { 450 const siteId = insertTestSite('count-test.com'); 451 452 // 3 scoring requests 453 for (let i = 0; i < 3; i++) { 454 logLLMUsage({ 455 siteId, 456 stage: 'scoring', 457 provider: 'openrouter', 458 model: 'openai/gpt-4o-mini', 459 promptTokens: 100, 460 completionTokens: 50, 461 }); 462 } 463 464 // 1 rescoring request 465 logLLMUsage({ 466 siteId, 467 stage: 'rescoring', 468 provider: 'openrouter', 469 model: 'openai/gpt-4o-mini', 470 promptTokens: 100, 471 completionTokens: 50, 472 }); 473 474 const result = getCostByStage(); 475 const scoringRow = result.find(r => r.stage === 'scoring'); 476 const rescoringRow = result.find(r => r.stage === 'rescoring'); 477 478 assert.equal(scoringRow.request_count, 3); 479 assert.equal(rescoringRow.request_count, 1); 480 }); 481 }); 482 }); 483 484 describe('checkBudgetVariance', () => { 485 before(() => { 486 initSchema(); 487 // llm_cost_budgets is only in migration 079, not in schema.sql — add it here 488 const db = new Database(testDb); 489 db.exec(` 490 CREATE TABLE IF NOT EXISTS llm_cost_budgets ( 491 call_type TEXT PRIMARY KEY, 492 expected_cost_per_call REAL NOT NULL, 493 max_cost_per_call REAL NOT NULL, 494 expected_model TEXT NOT NULL, 495 updated_at TEXT DEFAULT (datetime('now')) 496 ); 497 INSERT OR IGNORE INTO llm_cost_budgets (call_type, expected_cost_per_call, max_cost_per_call, expected_model) VALUES 498 ('scoring', 0.003, 0.009, 'openai/gpt-4o-mini'), 499 ('rescoring', 0.005, 0.015, 'openai/gpt-4o-mini'); 500 `); 501 db.close(); 502 }); 503 beforeEach(clearTables); 504 after(() => { 505 if (existsSync(testDb)) unlinkSync(testDb); 506 }); 507 508 test('returns empty array when no usage in last hour', () => { 509 const alerts = checkBudgetVariance(); 510 assert.ok(Array.isArray(alerts)); 511 assert.equal(alerts.length, 0); 512 }); 513 514 test('returns no alerts when cost is within budget', () => { 515 // Insert usage with cost well under the budget for scoring (max: 0.009) 516 const db = new Database(testDb); 517 db.prepare( 518 `INSERT INTO sites (domain, landing_page_url, keyword, status) VALUES ('test.com', 'https://test.com', 'test', 'found')` 519 ).run(); 520 const siteId = db.prepare('SELECT last_insert_rowid() as id').get().id; 521 db.prepare( 522 `INSERT INTO llm_usage (site_id, stage, provider, model, prompt_tokens, completion_tokens, total_tokens, estimated_cost) 523 VALUES (?, 'scoring', 'openrouter', 'openai/gpt-4o-mini', 100, 50, 150, 0.003)` 524 ).run(siteId); 525 db.close(); 526 527 const alerts = checkBudgetVariance(); 528 const costAlert = alerts.filter(a => a.type === 'cost_variance'); 529 assert.equal(costAlert.length, 0, 'No cost variance alert when within budget'); 530 }); 531 532 test('raises cost_variance alert when avg cost exceeds max', () => { 533 const db = new Database(testDb); 534 db.prepare( 535 `INSERT INTO sites (domain, landing_page_url, keyword, status) VALUES ('test.com', 'https://test.com', 'test', 'found')` 536 ).run(); 537 const siteId = db.prepare('SELECT last_insert_rowid() as id').get().id; 538 // scoring max_cost_per_call = 0.009; insert cost of 0.05 to trigger alert 539 db.prepare( 540 `INSERT INTO llm_usage (site_id, stage, provider, model, prompt_tokens, completion_tokens, total_tokens, estimated_cost) 541 VALUES (?, 'scoring', 'openrouter', 'openai/gpt-4o-mini', 10000, 5000, 15000, 0.05)` 542 ).run(siteId); 543 db.close(); 544 545 const alerts = checkBudgetVariance(); 546 const costAlerts = alerts.filter(a => a.type === 'cost_variance' && a.stage === 'scoring'); 547 assert.ok(costAlerts.length > 0, 'Should raise cost_variance alert'); 548 assert.equal(costAlerts[0].stage, 'scoring'); 549 }); 550 551 test('raises model_mismatch alert when wrong model used', () => { 552 const db = new Database(testDb); 553 db.prepare( 554 `INSERT INTO sites (domain, landing_page_url, keyword, status) VALUES ('test.com', 'https://test.com', 'test', 'found')` 555 ).run(); 556 const siteId = db.prepare('SELECT last_insert_rowid() as id').get().id; 557 // scoring expected model: openai/gpt-4o-mini; use wrong model 558 db.prepare( 559 `INSERT INTO llm_usage (site_id, stage, provider, model, prompt_tokens, completion_tokens, total_tokens, estimated_cost) 560 VALUES (?, 'scoring', 'openrouter', 'anthropic/claude-opus-4', 100, 50, 150, 0.003)` 561 ).run(siteId); 562 db.close(); 563 564 const alerts = checkBudgetVariance(); 565 const modelAlerts = alerts.filter(a => a.type === 'model_mismatch' && a.stage === 'scoring'); 566 assert.ok(modelAlerts.length > 0, 'Should raise model_mismatch alert'); 567 assert.equal(modelAlerts[0].actualModel, 'anthropic/claude-opus-4'); 568 assert.equal(modelAlerts[0].expectedModel, 'openai/gpt-4o-mini'); 569 }); 570 571 test('ignores stages not in llm_cost_budgets', () => { 572 const db = new Database(testDb); 573 db.prepare( 574 `INSERT INTO sites (domain, landing_page_url, keyword, status) VALUES ('test.com', 'https://test.com', 'test', 'found')` 575 ).run(); 576 const siteId = db.prepare('SELECT last_insert_rowid() as id').get().id; 577 // 'other' is in the CHECK constraint but not in llm_cost_budgets 578 db.prepare( 579 `INSERT INTO llm_usage (site_id, stage, provider, model, prompt_tokens, completion_tokens, total_tokens, estimated_cost) 580 VALUES (?, 'other', 'openrouter', 'openai/gpt-4o-mini', 100, 50, 150, 0.999)` 581 ).run(siteId); 582 db.close(); 583 584 const alerts = checkBudgetVariance(); 585 const otherAlerts = alerts.filter(a => a.stage === 'other'); 586 assert.equal(otherAlerts.length, 0, 'No alert for stage not in llm_cost_budgets'); 587 }); 588 }); 589 590 // ─── Helper: insert a usage row with explicit created_at ──────────────────── 591 function insertUsageWithTimestamp(db, { stage, model, cost, createdAt }) { 592 db.prepare( 593 `INSERT INTO llm_usage 594 (stage, provider, model, prompt_tokens, completion_tokens, total_tokens, estimated_cost, created_at) 595 VALUES (?, 'openrouter', ?, 100, 50, 150, ?, ?)` 596 ).run(stage, model, cost, createdAt); 597 } 598 599 // ─── getDailySpend ─────────────────────────────────────────────────────────── 600 601 describe('getDailySpend', () => { 602 before(() => { 603 initSchema(); 604 }); 605 beforeEach(clearTables); 606 after(() => { 607 if (existsSync(testDb)) unlinkSync(testDb); 608 }); 609 610 test('returns 0 when no usage today', () => { 611 const spend = getDailySpend(); 612 assert.equal(spend, 0); 613 }); 614 615 test('returns correct sum for usage rows with today UTC date', () => { 616 const db = new Database(testDb); 617 // Use SQLite date('now') which equals today in UTC 618 insertUsageWithTimestamp(db, { 619 stage: 'scoring', 620 model: 'openai/gpt-4o-mini', 621 cost: 0.5, 622 createdAt: new Date().toISOString().replace('T', ' ').substring(0, 19), 623 }); 624 insertUsageWithTimestamp(db, { 625 stage: 'proposals', 626 model: 'claude-3-5-sonnet-20241022', 627 cost: 0.25, 628 createdAt: new Date().toISOString().replace('T', ' ').substring(0, 19), 629 }); 630 db.close(); 631 632 const spend = getDailySpend(); 633 // 0.5 + 0.25 = 0.75 634 assert.ok(Math.abs(spend - 0.75) < 0.001, `Expected ~0.75 but got ${spend}`); 635 }); 636 637 test('excludes usage from a previous day', () => { 638 const db = new Database(testDb); 639 // Yesterday's row 640 insertUsageWithTimestamp(db, { 641 stage: 'scoring', 642 model: 'openai/gpt-4o-mini', 643 cost: 99.0, 644 createdAt: '2000-01-01 12:00:00', 645 }); 646 // Today's row 647 insertUsageWithTimestamp(db, { 648 stage: 'scoring', 649 model: 'openai/gpt-4o-mini', 650 cost: 0.1, 651 createdAt: new Date().toISOString().replace('T', ' ').substring(0, 19), 652 }); 653 db.close(); 654 655 const spend = getDailySpend(); 656 assert.ok(Math.abs(spend - 0.1) < 0.001, `Expected ~0.10 but got ${spend}`); 657 }); 658 659 test('returns a number (not string)', () => { 660 const spend = getDailySpend(); 661 assert.equal(typeof spend, 'number'); 662 }); 663 }); 664 665 // ─── getHourlySpend ────────────────────────────────────────────────────────── 666 667 describe('getHourlySpend', () => { 668 before(() => { 669 initSchema(); 670 }); 671 beforeEach(clearTables); 672 after(() => { 673 if (existsSync(testDb)) unlinkSync(testDb); 674 }); 675 676 test('returns 0 when no usage in last hour', () => { 677 const spend = getHourlySpend(); 678 assert.equal(spend, 0); 679 }); 680 681 test('returns correct sum for usage in the last hour', () => { 682 const db = new Database(testDb); 683 const nowIso = new Date().toISOString().replace('T', ' ').substring(0, 19); 684 insertUsageWithTimestamp(db, { 685 stage: 'scoring', 686 model: 'openai/gpt-4o-mini', 687 cost: 0.3, 688 createdAt: nowIso, 689 }); 690 insertUsageWithTimestamp(db, { 691 stage: 'enrichment', 692 model: 'openai/gpt-4o-mini', 693 cost: 0.2, 694 createdAt: nowIso, 695 }); 696 db.close(); 697 698 const spend = getHourlySpend(); 699 assert.ok(Math.abs(spend - 0.5) < 0.001, `Expected ~0.50 but got ${spend}`); 700 }); 701 702 test('excludes usage older than 1 hour', () => { 703 const db = new Database(testDb); 704 // Row created 2 hours ago (well outside the window) 705 const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000) 706 .toISOString() 707 .replace('T', ' ') 708 .substring(0, 19); 709 insertUsageWithTimestamp(db, { 710 stage: 'scoring', 711 model: 'openai/gpt-4o-mini', 712 cost: 50.0, 713 createdAt: twoHoursAgo, 714 }); 715 // Recent row 716 const nowIso = new Date().toISOString().replace('T', ' ').substring(0, 19); 717 insertUsageWithTimestamp(db, { 718 stage: 'scoring', 719 model: 'openai/gpt-4o-mini', 720 cost: 0.05, 721 createdAt: nowIso, 722 }); 723 db.close(); 724 725 const spend = getHourlySpend(); 726 assert.ok(Math.abs(spend - 0.05) < 0.001, `Expected ~0.05 but got ${spend}`); 727 }); 728 729 test('returns a number (not string)', () => { 730 const spend = getHourlySpend(); 731 assert.equal(typeof spend, 'number'); 732 }); 733 });