mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Add two new features for LAN sharing governance:
Quota (budget control):
- Per-key daily/weekly/monthly request limits (NULL = unlimited)
- Idempotent schema migration for quota columns
- Single-query check (SUM/CASE) for all 3 periods — no N+1
- PATCH /api/keys/:id/quota (partial update, input validation)
- GET /api/keys/:id/quota (current limits + usage)
- 429 with structured error when exceeded
- Only applies to identified per-key users, not admin/anonymous
Response cache:
- SHA-256 hash of model + messages + temperature/max_tokens/top_p
- Opt-in via CLAUDE_CACHE_TTL env var (0 = disabled, default)
- Cache hit serves both streaming (simulated SSE) and non-streaming
- Streaming responses accumulated and cached on success
- Skips multi-turn sessions (conversationId present)
- GET /cache/stats, DELETE /cache admin endpoints
- Runtime-tunable cacheTTL via PATCH /settings
- 10-minute periodic cleanup of expired entries
Bug fix discovered during testing:
- SQLite datetime('now') stores 'YYYY-MM-DD HH:MM:SS' but JS
.toISOString() produces 'YYYY-MM-DDTHH:MM:SS.sssZ'. String
comparison breaks for same-day ranges. Added sqliteDatetime()
helper for correct format matching.
Code review fixes:
- DELETE /api/keys/:id no longer shadows /quota sub-routes
- updateKeyQuota uses partial UPDATE (only SET provided fields)
- cacheHash includes temperature/max_tokens/top_p in hash
- Replaced raw getDb() in server.mjs with findKey() encapsulation
- Unified UTC midnight calculation across checkQuota/getKeyQuota
Includes 24-test integration suite (test-features.mjs).
Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
261 lines
8.4 KiB
JavaScript
261 lines
8.4 KiB
JavaScript
#!/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);
|