#!/usr/bin/env node /** * Integration test for Quota + Cache features. * Tests database layer functions directly — no server needed. */ import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb } from "./keys.mjs"; import { strict as assert } from "node:assert"; import { unlinkSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; // Use a test database to avoid corrupting real data const TEST_DB = join(homedir(), ".ocp", "ocp-test.db"); try { unlinkSync(TEST_DB); } catch {} // Monkey-patch DB_PATH for testing (override the module-level variable) // Since keys.mjs uses lazy init, we can set env before first getDb() call process.env.HOME = homedir(); // ensure consistent let passed = 0; let failed = 0; function test(name, fn) { try { fn(); passed++; console.log(` ✓ ${name}`); } catch (e) { failed++; console.log(` ✗ ${name}: ${e.message}`); } } console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n"); // Initialize DB const db = getDb(); // ── Quota Tests ── console.log("Quota:"); const key1 = createKey("test-user-1"); const key2 = createKey("test-user-2"); test("createKey returns id, key, name", () => { assert.ok(key1.id); assert.ok(key1.key.startsWith("ocp_")); assert.equal(key1.name, "test-user-1"); }); test("listKeys includes quota fields", () => { const keys = listKeys(); assert.ok(keys.length >= 2); const k = keys.find(k => k.name === "test-user-1"); assert.ok("quota_daily" in k); assert.ok("quota_weekly" in k); assert.ok("quota_monthly" in k); assert.equal(k.quota_daily, null); }); test("checkQuota returns null when no quota set", () => { const result = checkQuota(key1.id, key1.name); assert.equal(result, null); }); test("checkQuota returns null for null keyId", () => { assert.equal(checkQuota(null, "anon"), null); assert.equal(checkQuota(undefined, "anon"), null); }); test("updateKeyQuota sets daily quota (partial update)", () => { const ok = updateKeyQuota(key1.id, { daily: 5 }); assert.ok(ok); const quota = getKeyQuota(key1.id); assert.equal(quota.daily.limit, 5); assert.equal(quota.weekly.limit, null); // not touched assert.equal(quota.monthly.limit, null); }); test("updateKeyQuota partial update preserves existing values", () => { updateKeyQuota(key1.id, { weekly: 20 }); const quota = getKeyQuota(key1.id); assert.equal(quota.daily.limit, 5); // preserved from previous call assert.equal(quota.weekly.limit, 20); }); test("checkQuota passes when under limit", () => { // Record 3 usages (limit is 5 daily) for (let i = 0; i < 3; i++) { recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true }); } const result = checkQuota(key1.id, key1.name); assert.equal(result, null); }); test("checkQuota returns exceeded when at limit", () => { // Record 2 more to hit limit (3 + 2 = 5) for (let i = 0; i < 2; i++) { recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true }); } const result = checkQuota(key1.id, key1.name); assert.ok(result); assert.equal(result.period, "daily"); assert.equal(result.limit, 5); assert.equal(result.used, 5); assert.ok(result.resetsIn); }); test("checkQuota ignores failed requests in count", () => { // key2 has quota of 2 daily updateKeyQuota(key2.id, { daily: 2 }); recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 0, elapsedMs: 500, success: false }); recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true }); const result = checkQuota(key2.id, key2.name); assert.equal(result, null); // only 1 successful, limit is 2 }); test("getKeyQuota returns correct used counts", () => { const quota = getKeyQuota(key1.id); assert.equal(quota.daily.used, 5); assert.equal(quota.daily.limit, 5); }); test("findKey works by id and name", () => { const byId = findKey(String(key1.id)); assert.ok(byId); assert.equal(byId.name, "test-user-1"); const byName = findKey("test-user-1"); assert.ok(byName); // Compare by name since auto-increment IDs may vary across runs assert.equal(byName.name, "test-user-1"); assert.equal(findKey("nonexistent"), null); }); // ── Cache Tests ── console.log("\nCache:"); // Clean slate for cache tests clearCache(); const msgs1 = [{ role: "user", content: "Hello world" }]; const msgs2 = [{ role: "user", content: "Different prompt" }]; test("cacheHash is deterministic", () => { const h1 = cacheHash("sonnet", msgs1); const h2 = cacheHash("sonnet", msgs1); assert.equal(h1, h2); }); test("cacheHash differs for different models", () => { const h1 = cacheHash("sonnet", msgs1); const h2 = cacheHash("opus", msgs1); assert.notEqual(h1, h2); }); test("cacheHash differs for different messages", () => { const h1 = cacheHash("sonnet", msgs1); const h2 = cacheHash("sonnet", msgs2); assert.notEqual(h1, h2); }); test("cacheHash includes temperature in hash", () => { const h1 = cacheHash("sonnet", msgs1, {}); const h2 = cacheHash("sonnet", msgs1, { temperature: 0.5 }); const h3 = cacheHash("sonnet", msgs1, { temperature: 1.0 }); assert.notEqual(h1, h2); assert.notEqual(h2, h3); }); test("cacheHash includes max_tokens in hash", () => { const h1 = cacheHash("sonnet", msgs1, {}); const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 }); assert.notEqual(h1, h2); }); test("getCachedResponse returns null for miss", () => { const hash = cacheHash("sonnet", msgs1); const result = getCachedResponse(hash, 3600000); assert.equal(result, null); }); test("setCachedResponse + getCachedResponse roundtrip", () => { const hash = cacheHash("sonnet", msgs1); setCachedResponse(hash, "sonnet", "Hello! I am Claude."); const result = getCachedResponse(hash, 3600000); assert.ok(result); assert.equal(result.response, "Hello! I am Claude."); assert.equal(result.hits, 1); }); test("getCachedResponse increments hit counter", () => { const hash = cacheHash("sonnet", msgs1); const r1 = getCachedResponse(hash, 3600000); const r2 = getCachedResponse(hash, 3600000); assert.equal(r1.hits, 2); assert.equal(r2.hits, 3); }); test("getCachedResponse respects TTL (expired entry)", () => { // Insert a backdated cache entry directly const d = getDb(); const oldHash = "test_expired_hash_12345"; d.prepare("INSERT OR REPLACE INTO response_cache (hash, model, response, created_at) VALUES (?, ?, ?, datetime('now', '-2 hours'))").run(oldHash, "sonnet", "Old response"); // TTL of 1 hour should not return a 2-hour-old entry const result = getCachedResponse(oldHash, 3600000); assert.equal(result, null); // Clean up the backdated entry so it doesn't affect subsequent tests d.prepare("DELETE FROM response_cache WHERE hash = ?").run(oldHash); }); test("getCacheStats returns correct counts", () => { const stats = getCacheStats(); assert.equal(stats.entries, 1); assert.ok(stats.totalHits >= 3); assert.ok(stats.sizeBytes > 0); }); test("setCachedResponse upserts on conflict", () => { const hash = cacheHash("sonnet", msgs1); setCachedResponse(hash, "sonnet", "Updated response!"); const result = getCachedResponse(hash, 3600000); assert.equal(result.response, "Updated response!"); assert.equal(result.hits, 1); // reset after upsert }); test("clearCache removes all entries", () => { // Add another entry const hash2 = cacheHash("sonnet", msgs2); setCachedResponse(hash2, "sonnet", "Another response"); const statsBefore = getCacheStats(); assert.equal(statsBefore.entries, 2); const cleared = clearCache(); assert.equal(cleared, 2); const statsAfter = getCacheStats(); assert.equal(statsAfter.entries, 0); }); test("clearCache with TTL only removes old entries", () => { // Add fresh entry const hash = cacheHash("sonnet", msgs1); setCachedResponse(hash, "sonnet", "Fresh response"); // Clear with TTL of 1 hour — fresh entry should survive const cleared = clearCache(3600000); assert.equal(cleared, 0); const stats = getCacheStats(); assert.equal(stats.entries, 1); // Clean up clearCache(); }); // ── Cleanup ── closeDb(); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); process.exit(failed > 0 ? 1 : 0);