/ tests / agents / claude-api.test.js
claude-api.test.js
  1  /**
  2   * Agent-Claude API Tests
  3   *
  4   * Tests for centralized Claude API interface with budget tracking.
  5   * Uses pg-mock (in-memory SQLite via db.js mock) for PG compatibility.
  6   */
  7  
  8  import { describe, it, before, beforeEach, mock } from 'node:test';
  9  import assert from 'node:assert';
 10  import Database from 'better-sqlite3';
 11  import { createPgMock } from '../helpers/pg-mock.js';
 12  
 13  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 14  
 15  const db = new Database(':memory:');
 16  
 17  db.exec(`
 18    CREATE TABLE agent_tasks (
 19      id INTEGER PRIMARY KEY AUTOINCREMENT,
 20      task_type TEXT NOT NULL,
 21      assigned_to TEXT NOT NULL,
 22      status TEXT DEFAULT 'pending',
 23      priority INTEGER DEFAULT 5,
 24      context_json TEXT,
 25      result_json TEXT,
 26      parent_task_id INTEGER,
 27      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 28    );
 29    CREATE TABLE agent_llm_usage (
 30      id INTEGER PRIMARY KEY AUTOINCREMENT,
 31      agent_name TEXT NOT NULL CHECK(agent_name IN (
 32        'developer', 'qa', 'architect', 'monitor', 'security', 'runner', 'triage'
 33      )),
 34      task_id INTEGER REFERENCES agent_tasks(id),
 35      model TEXT NOT NULL,
 36      prompt_tokens INTEGER NOT NULL,
 37      completion_tokens INTEGER NOT NULL,
 38      cost_usd REAL NOT NULL,
 39      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 40    );
 41    CREATE INDEX IF NOT EXISTS idx_agent_llm_usage_agent ON agent_llm_usage(agent_name);
 42    CREATE INDEX IF NOT EXISTS idx_agent_llm_usage_created ON agent_llm_usage(created_at);
 43    CREATE INDEX IF NOT EXISTS idx_agent_llm_usage_agent_date ON agent_llm_usage(agent_name, DATE(created_at));
 44  `);
 45  
 46  // ─── Mock db.js BEFORE importing agent-claude-api.js ─────────────────────────
 47  
 48  mock.module('../../src/utils/db.js', {
 49    namedExports: createPgMock(db),
 50  });
 51  
 52  mock.module('../../src/utils/logger.js', {
 53    defaultExport: class {
 54      info() {}
 55      warn() {}
 56      error() {}
 57      success() {}
 58      debug() {}
 59    },
 60  });
 61  
 62  mock.module('../../src/utils/llm-provider.js', {
 63    namedExports: {
 64      callLLM: async () => ({ content: '' }),
 65      getProvider: () => 'openrouter',
 66      getProviderDisplayName: () => 'OpenRouter',
 67    },
 68  });
 69  
 70  // Import AFTER mock.module
 71  const {
 72    selectModel,
 73    getTodaySpending,
 74    getHourlySpending,
 75    getUsageStats,
 76    analyzeCode,
 77  } = await import('../../src/agents/utils/agent-claude-api.js');
 78  
 79  // ─── Helpers ─────────────────────────────────────────────────────────────────
 80  
 81  function clearUsage() {
 82    db.prepare('DELETE FROM agent_llm_usage').run();
 83    db.prepare('DELETE FROM agent_tasks').run();
 84  }
 85  
 86  function insertUsage(agentName, costUsd, createdAt = null) {
 87    if (createdAt) {
 88      db.prepare(
 89        `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd, created_at)
 90         VALUES (?, NULL, 'claude-3-5-sonnet-20241022', 100, 50, ?, ?)`
 91      ).run(agentName, costUsd, createdAt);
 92    } else {
 93      db.prepare(
 94        `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
 95         VALUES (?, NULL, 'claude-3-5-sonnet-20241022', 100, 50, ?)`
 96      ).run(agentName, costUsd);
 97    }
 98  }
 99  
100  // ─── Tests ───────────────────────────────────────────────────────────────────
101  
102  describe('Agent-Claude API Module', () => {
103    before(() => {
104      process.env.ANTHROPIC_API_KEY = 'test-api-key-12345';
105      process.env.AGENT_DAILY_BUDGET = '10';
106    });
107  
108    describe('Database integration', () => {
109      beforeEach(() => clearUsage());
110  
111      it('should track daily spending', async () => {
112        // Initially zero
113        const initialSpending = await getTodaySpending();
114        assert.strictEqual(initialSpending, 0);
115  
116        // Add some usage
117        insertUsage('developer', 0.001);
118  
119        // Should now show spending
120        const currentSpending = await getTodaySpending();
121        assert.strictEqual(currentSpending, 0.001);
122      });
123  
124      it('should track hourly spending', async () => {
125        // Use SQLite's built-in datetime functions to insert at correct times
126        // so the comparison with datetime('now', '-1 hour') works correctly
127        db.prepare(
128          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd, created_at)
129           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.002, datetime('now', '-30 minutes'))`
130        ).run();
131  
132        db.prepare(
133          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd, created_at)
134           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.005, datetime('now', '-2 hours'))`
135        ).run();
136  
137        const hourlySpending = await getHourlySpending();
138        // 30-min-ago row should be counted (0.002), 2-hour-ago should not
139        assert.ok(hourlySpending >= 0.002, `Expected >= 0.002, got ${hourlySpending}`);
140        assert.ok(hourlySpending < 0.005, `Expected < 0.005, got ${hourlySpending}`);
141      });
142  
143      it('should return usage stats for all agents', async () => {
144        // Add usage for multiple agents
145        insertUsage('developer', 0.001);
146        insertUsage('developer', 0.002);
147        insertUsage('qa', 0.0015);
148  
149        const stats = await getUsageStats();
150  
151        assert.ok(Array.isArray(stats.agents));
152        assert.strictEqual(stats.agents.length, 2); // developer and qa
153        assert.ok(stats.totalCost > 0);
154        assert.strictEqual(stats.days, 7);
155  
156        // Check developer stats
157        const devStats = stats.agents.find(a => a.agent_name === 'developer');
158        assert.ok(devStats);
159        assert.strictEqual(Number(devStats.call_count), 2);
160        assert.ok(Math.abs(Number(devStats.total_cost_usd) - 0.003) < 0.000001);
161      });
162  
163      it('should filter stats by agent name', async () => {
164        insertUsage('developer', 0.001);
165        insertUsage('qa', 0.0015);
166  
167        const stats = await getUsageStats('developer');
168  
169        assert.ok(Array.isArray(stats.agents));
170        assert.strictEqual(stats.agents.length, 1);
171        assert.strictEqual(stats.agents[0].agent_name, 'developer');
172      });
173  
174      it('should support custom time range', async () => {
175        // Add recent usage (today)
176        insertUsage('developer', 0.001);
177  
178        // Add old usage outside range
179        const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
180        insertUsage('developer', 0.001, tenDaysAgo);
181  
182        const stats = await getUsageStats(null, 1); // Last 1 day
183  
184        assert.strictEqual(stats.days, 1);
185        // Only today's record should be counted
186        if (stats.agents.length > 0) {
187          assert.strictEqual(Number(stats.agents[0].call_count), 1);
188        }
189      });
190  
191      it('should maintain referential integrity with agent_tasks', async () => {
192        // Create task
193        const taskResult = db.prepare(
194          `INSERT INTO agent_tasks (task_type, assigned_to) VALUES ('test_task', 'developer')`
195        ).run();
196        const taskId = taskResult.lastInsertRowid;
197  
198        // Add usage for that task
199        db.prepare(
200          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
201           VALUES ('developer', ?, 'claude-3-5-sonnet-20241022', 100, 50, 0.001)`
202        ).run(taskId);
203  
204        const usage = db.prepare('SELECT * FROM agent_llm_usage WHERE task_id = ?').get(taskId);
205  
206        assert.strictEqual(usage.task_id, taskId);
207        assert.strictEqual(usage.agent_name, 'developer');
208      });
209  
210      it('should support null task_id', async () => {
211        db.prepare(
212          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
213           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.001)`
214        ).run();
215  
216        const usage = db.prepare('SELECT * FROM agent_llm_usage WHERE task_id IS NULL').get();
217  
218        assert.strictEqual(usage.task_id, null);
219        assert.strictEqual(usage.agent_name, 'developer');
220      });
221    });
222  
223    describe('Budget enforcement', () => {
224      beforeEach(() => clearUsage());
225  
226      it('should calculate cost correctly for Sonnet', () => {
227        // Cost for 1M input + 1M output tokens
228        // (1M/1M * $3) + (1M/1M * $15) = $18
229        db.prepare(
230          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
231           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 1000000, 1000000, 18.0)`
232        ).run();
233  
234        const usage = db.prepare('SELECT cost_usd FROM agent_llm_usage').get();
235  
236        assert.strictEqual(usage.cost_usd, 18.0);
237      });
238  
239      it('should track fractional costs accurately', () => {
240        // Cost = (1234/1M * $3) + (567/1M * $15)
241        const expectedCost = (1234 / 1_000_000) * 3 + (567 / 1_000_000) * 15;
242  
243        db.prepare(
244          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
245           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 1234, 567, ?)`
246        ).run(expectedCost);
247  
248        const usage = db.prepare('SELECT cost_usd FROM agent_llm_usage').get();
249  
250        assert.ok(Math.abs(usage.cost_usd - expectedCost) < 0.000001);
251      });
252    });
253  
254    describe('Schema validation', () => {
255      beforeEach(() => clearUsage());
256  
257      it('should enforce agent_name CHECK constraint', () => {
258        // Valid agent name
259        db.prepare(
260          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
261           VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.001)`
262        ).run();
263  
264        // Invalid agent name should fail
265        assert.throws(
266          () => {
267            db.prepare(
268              `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
269               VALUES ('invalid_agent', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.001)`
270            ).run();
271          },
272          {
273            message: /CHECK constraint failed/,
274          }
275        );
276      });
277  
278      it('should require all non-null fields', () => {
279        // Missing required fields should fail
280        assert.throws(
281          () => {
282            db.prepare(
283              `INSERT INTO agent_llm_usage (agent_name) VALUES ('developer')`
284            ).run();
285          },
286          /NOT NULL constraint failed/
287        );
288      });
289  
290      it('should have proper indexes', () => {
291        const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all();
292        const indexNames = indexes.map(idx => idx.name);
293        assert.ok(indexNames.includes('idx_agent_llm_usage_agent'));
294        assert.ok(indexNames.includes('idx_agent_llm_usage_created'));
295      });
296    });
297  
298    describe('API integration (mocked)', () => {
299      it('should validate OPENROUTER_API_KEY is required', async () => {
300        const savedKey = process.env.OPENROUTER_API_KEY;
301        delete process.env.OPENROUTER_API_KEY;
302        delete process.env.ANTHROPIC_API_KEY;
303  
304        try {
305          await analyzeCode('developer', 1, '/src/test.js', 'Test');
306          assert.fail('Should have thrown error for missing API key');
307        } catch (error) {
308          assert.match(error.message, /No LLM API key configured|OPENROUTER_API_KEY/);
309        } finally {
310          if (savedKey) process.env.OPENROUTER_API_KEY = savedKey;
311          process.env.ANTHROPIC_API_KEY = 'test-api-key-12345';
312        }
313      });
314    });
315  
316    describe('Performance', () => {
317      beforeEach(() => clearUsage());
318  
319      it('should handle large batch inserts efficiently', () => {
320        const insertStmt = db.prepare(
321          `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd)
322           VALUES (?, ?, 'claude-3-5-sonnet-20241022', 100, 50, 0.001)`
323        );
324  
325        const startTime = Date.now();
326  
327        const insertMany = db.transaction(records => {
328          for (const record of records) {
329            insertStmt.run(record.agent, record.taskId);
330          }
331        });
332  
333        const records = Array.from({ length: 1000 }, () => ({
334          agent: 'developer',
335          taskId: null,
336        }));
337  
338        insertMany(records);
339  
340        const duration = Date.now() - startTime;
341        const count = db.prepare('SELECT COUNT(*) as count FROM agent_llm_usage').get();
342  
343        assert.strictEqual(count.count, 1000);
344        assert.ok(duration < 1000, `Batch insert took ${duration}ms (should be <1000ms)`);
345      });
346  
347      it('should query by date efficiently with index', () => {
348        // Insert records across multiple days
349        for (let i = 0; i < 100; i++) {
350          const date = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString();
351          db.prepare(
352            `INSERT INTO agent_llm_usage (agent_name, task_id, model, prompt_tokens, completion_tokens, cost_usd, created_at)
353             VALUES ('developer', NULL, 'claude-3-5-sonnet-20241022', 100, 50, 0.001, ?)`
354          ).run(date);
355        }
356  
357        const startTime = Date.now();
358  
359        // Query recent records (should use index)
360        const recent = db.prepare(
361          `SELECT * FROM agent_llm_usage WHERE created_at >= datetime('now', '-7 days')`
362        ).all();
363  
364        const duration = Date.now() - startTime;
365  
366        assert.ok(recent.length <= 8); // 7 days + today
367        assert.ok(duration < 50, `Query took ${duration}ms (should be <50ms with index)`);
368      });
369    });
370  
371    describe('selectModel', () => {
372      it('returns haiku for simple task types', () => {
373        assert.match(selectModel('classify_issue'), /haiku/);
374        assert.match(selectModel('scan_logs'), /haiku/);
375        assert.match(selectModel('scan_secrets'), /haiku/);
376      });
377  
378      it('returns sonnet for complex task types', () => {
379        assert.match(selectModel('generateCode'), /sonnet/);
380        assert.match(selectModel('reviewArchitecture'), /sonnet/);
381        assert.match(selectModel('threat_model'), /sonnet/);
382      });
383  
384      it('returns sonnet for unknown task types', () => {
385        assert.match(selectModel('unknown_task_xyz'), /sonnet/);
386      });
387  
388      it('explicit simple complexity override returns haiku', () => {
389        assert.match(selectModel('generateCode', 'simple'), /haiku/);
390      });
391  
392      it('explicit complex complexity override returns sonnet', () => {
393        assert.match(selectModel('classify_issue', 'complex'), /sonnet/);
394      });
395  
396      it('AGENT_USE_HAIKU_FOR_SIMPLE_TASKS=false always returns sonnet', () => {
397        process.env.AGENT_USE_HAIKU_FOR_SIMPLE_TASKS = 'false';
398        assert.match(selectModel('classify_issue'), /sonnet/);
399        delete process.env.AGENT_USE_HAIKU_FOR_SIMPLE_TASKS;
400      });
401    });
402  });