363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-domainstate-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const ds = await import("../../src/lib/db/domainState.ts");
|
|
|
|
async function resetStorage() {
|
|
core.resetDbInstance();
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
try {
|
|
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
break;
|
|
} catch {
|
|
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
|
|
}
|
|
}
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
core.getDbInstance();
|
|
}
|
|
|
|
await resetStorage();
|
|
|
|
// ──────────────── Fallback Chains ────────────────
|
|
|
|
test("saveFallbackChain and loadFallbackChain round-trip", async () => {
|
|
await resetStorage();
|
|
const model = "gpt-4";
|
|
const chain = [
|
|
{ provider: "openai", priority: 1, enabled: true },
|
|
{ provider: "anthropic", priority: 2, enabled: false },
|
|
];
|
|
|
|
ds.saveFallbackChain(model, chain);
|
|
const loaded = ds.loadFallbackChain(model);
|
|
assert.deepEqual(loaded, chain);
|
|
});
|
|
|
|
test("loadFallbackChain returns null for missing model", async () => {
|
|
await resetStorage();
|
|
const result = ds.loadFallbackChain("nonexistent");
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("loadAllFallbackChains returns all chains", async () => {
|
|
await resetStorage();
|
|
ds.saveFallbackChain("model-a", [{ provider: "p1", priority: 1, enabled: true }]);
|
|
ds.saveFallbackChain("model-b", [{ provider: "p2", priority: 2, enabled: false }]);
|
|
|
|
const all = ds.loadAllFallbackChains();
|
|
assert.ok("model-a" in all);
|
|
assert.ok("model-b" in all);
|
|
assert.equal((all["model-a"] as any[]).length, 1);
|
|
});
|
|
|
|
test("deleteFallbackChain removes a chain", async () => {
|
|
await resetStorage();
|
|
ds.saveFallbackChain("to-delete", [{ provider: "p", priority: 1, enabled: true }]);
|
|
assert.equal(ds.deleteFallbackChain("to-delete"), true);
|
|
assert.equal(ds.loadFallbackChain("to-delete"), null);
|
|
});
|
|
|
|
test("deleteFallbackChain returns false when chain does not exist", async () => {
|
|
await resetStorage();
|
|
assert.equal(ds.deleteFallbackChain("never-existed"), false);
|
|
});
|
|
|
|
test("deleteAllFallbackChains clears everything", async () => {
|
|
await resetStorage();
|
|
ds.saveFallbackChain("a", [{ provider: "p", priority: 1, enabled: true }]);
|
|
ds.saveFallbackChain("b", [{ provider: "p", priority: 1, enabled: true }]);
|
|
ds.deleteAllFallbackChains();
|
|
assert.deepEqual(ds.loadAllFallbackChains(), {});
|
|
});
|
|
|
|
// ──────────────── Budgets ────────────────
|
|
|
|
test("saveBudget and loadBudget round-trip", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("key-1", {
|
|
dailyLimitUsd: 10,
|
|
weeklyLimitUsd: 50,
|
|
monthlyLimitUsd: 200,
|
|
warningThreshold: 0.8,
|
|
resetInterval: "daily",
|
|
resetTime: "08:00",
|
|
budgetResetAt: 1000,
|
|
lastBudgetResetAt: 500,
|
|
warningEmittedAt: 900,
|
|
warningPeriodStart: 800,
|
|
});
|
|
|
|
const loaded = ds.loadBudget("key-1");
|
|
assert.ok(loaded !== null);
|
|
assert.equal(loaded.dailyLimitUsd, 10);
|
|
assert.equal(loaded.weeklyLimitUsd, 50);
|
|
assert.equal(loaded.monthlyLimitUsd, 200);
|
|
assert.equal(loaded.warningThreshold, 0.8);
|
|
assert.equal(loaded.resetInterval, "daily");
|
|
assert.equal(loaded.resetTime, "08:00");
|
|
assert.equal(loaded.budgetResetAt, 1000);
|
|
assert.equal(loaded.lastBudgetResetAt, 500);
|
|
assert.equal(loaded.warningEmittedAt, 900);
|
|
assert.equal(loaded.warningPeriodStart, 800);
|
|
});
|
|
|
|
test("loadBudget returns null for missing key", () => {
|
|
assert.equal(ds.loadBudget("no-such-key"), null);
|
|
});
|
|
|
|
test("saveBudget with minimal fields uses defaults", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("key-minimal", {});
|
|
const loaded = ds.loadBudget("key-minimal");
|
|
assert.ok(loaded !== null);
|
|
assert.equal(loaded.dailyLimitUsd, 0);
|
|
assert.equal(loaded.warningThreshold, 0.8);
|
|
assert.equal(loaded.resetInterval, "daily");
|
|
assert.equal(loaded.resetTime, "00:00");
|
|
assert.equal(loaded.budgetResetAt, null);
|
|
assert.equal(loaded.lastBudgetResetAt, null);
|
|
});
|
|
|
|
test("loadAllBudgets returns all budget configs", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("key-a", { dailyLimitUsd: 5 });
|
|
ds.saveBudget("key-b", { dailyLimitUsd: 10 });
|
|
|
|
const all = ds.loadAllBudgets();
|
|
assert.equal(Object.keys(all).length, 2);
|
|
assert.equal(all["key-a"].dailyLimitUsd, 5);
|
|
assert.equal(all["key-b"].dailyLimitUsd, 10);
|
|
});
|
|
|
|
test("saveBudgetResetLog and loadBudgetResetLogs", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("budget-key", { dailyLimitUsd: 10 });
|
|
|
|
const now = Date.now();
|
|
ds.saveBudgetResetLog({
|
|
apiKeyId: "budget-key",
|
|
resetInterval: "daily",
|
|
previousSpend: 8,
|
|
resetAt: now,
|
|
nextResetAt: now + 86400000,
|
|
periodStart: now - 86400000,
|
|
periodEnd: now,
|
|
});
|
|
|
|
const logs = ds.loadBudgetResetLogs("budget-key");
|
|
assert.equal(logs.length, 1);
|
|
assert.equal(logs[0].previousSpend, 8);
|
|
assert.equal(logs[0].resetInterval, "daily");
|
|
|
|
const noLogs = ds.loadBudgetResetLogs("no-such-key");
|
|
assert.deepEqual(noLogs, []);
|
|
});
|
|
|
|
test("deleteBudget removes budget and reset logs", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("del-key", { dailyLimitUsd: 10 });
|
|
ds.saveBudgetResetLog({ apiKeyId: "del-key", resetInterval: "daily", previousSpend: 3, resetAt: 1, nextResetAt: 2, periodStart: 0, periodEnd: 1 });
|
|
ds.deleteBudget("del-key");
|
|
assert.equal(ds.loadBudget("del-key"), null);
|
|
assert.deepEqual(ds.loadBudgetResetLogs("del-key"), []);
|
|
});
|
|
|
|
// ──────────────── Cost History ────────────────
|
|
|
|
test("saveCostEntry and loadCostTotal", async () => {
|
|
await resetStorage();
|
|
ds.saveCostEntry("cost-key", 1.5, 1000);
|
|
ds.saveCostEntry("cost-key", 2.5, 2000);
|
|
ds.saveCostEntry("cost-key", 3.0, 3000);
|
|
|
|
const total = ds.loadCostTotal("cost-key", 1500);
|
|
assert.equal(total, 5.5); // 2.5 + 3.0
|
|
|
|
const all = ds.loadCostTotal("cost-key", 0);
|
|
assert.equal(all, 7.0);
|
|
});
|
|
|
|
test("loadCostTotal returns 0 for no entries", () => {
|
|
assert.equal(ds.loadCostTotal("no-key", 0), 0);
|
|
});
|
|
|
|
test("batchSaveCostEntries inserts multiple entries", async () => {
|
|
await resetStorage();
|
|
ds.batchSaveCostEntries([
|
|
{ apiKeyId: "batch-key", cost: 1, timestamp: 100 },
|
|
{ apiKeyId: "batch-key", cost: 2, timestamp: 200 },
|
|
]);
|
|
assert.equal(ds.loadCostTotal("batch-key", 0), 3);
|
|
});
|
|
|
|
test("batchSaveCostEntries skips empty array", () => {
|
|
assert.doesNotThrow(() => ds.batchSaveCostEntries([]));
|
|
});
|
|
|
|
test("loadCostEntries returns entries in order", async () => {
|
|
await resetStorage();
|
|
ds.saveCostEntry("ce-key", 1, 100);
|
|
ds.saveCostEntry("ce-key", 2, 200);
|
|
ds.saveCostEntry("ce-key", 3, 300);
|
|
|
|
const entries = ds.loadCostEntries("ce-key", 150);
|
|
assert.equal(entries.length, 2);
|
|
assert.equal((entries[0] as any).cost, 2);
|
|
assert.equal((entries[1] as any).cost, 3);
|
|
});
|
|
|
|
test("loadCostEntriesInRange returns bounded entries", async () => {
|
|
await resetStorage();
|
|
ds.saveCostEntry("range-key", 1, 100);
|
|
ds.saveCostEntry("range-key", 2, 200);
|
|
ds.saveCostEntry("range-key", 3, 300);
|
|
|
|
const entries = ds.loadCostEntriesInRange("range-key", 150, 250);
|
|
assert.equal(entries.length, 1);
|
|
assert.equal((entries[0] as any).cost, 2);
|
|
});
|
|
|
|
test("cleanOldCostEntries deletes old entries", async () => {
|
|
await resetStorage();
|
|
ds.saveCostEntry("clean-key", 1, 100);
|
|
ds.saveCostEntry("clean-key", 2, 200);
|
|
ds.saveCostEntry("clean-key", 3, 300);
|
|
|
|
const deleted = ds.cleanOldCostEntries(250);
|
|
assert.equal(deleted, 2); // entries at 100 and 200
|
|
assert.equal(ds.loadCostTotal("clean-key", 0), 3);
|
|
});
|
|
|
|
test("deleteCostEntries removes all for key", async () => {
|
|
await resetStorage();
|
|
ds.saveCostEntry("del-cost", 5, 100);
|
|
ds.saveCostEntry("del-cost", 10, 200);
|
|
ds.deleteCostEntries("del-cost");
|
|
assert.equal(ds.loadCostTotal("del-cost", 0), 0);
|
|
});
|
|
|
|
test("deleteAllCostData wipes budgets and cost data", async () => {
|
|
await resetStorage();
|
|
ds.saveBudget("wipe-key", { dailyLimitUsd: 10 });
|
|
ds.saveCostEntry("wipe-key", 5, 100);
|
|
ds.deleteAllCostData();
|
|
assert.equal(ds.loadBudget("wipe-key"), null);
|
|
assert.equal(ds.loadCostTotal("wipe-key", 0), 0);
|
|
});
|
|
|
|
// ──────────────── Lockout State ────────────────
|
|
|
|
test("saveLockoutState and loadLockoutState round-trip", async () => {
|
|
await resetStorage();
|
|
ds.saveLockoutState("user-1", { attempts: [100, 200, 300], lockedUntil: 9999999999999 });
|
|
const loaded = ds.loadLockoutState("user-1");
|
|
assert.ok(loaded !== null);
|
|
assert.deepEqual(loaded.attempts, [100, 200, 300]);
|
|
assert.equal(loaded.lockedUntil, 9999999999999);
|
|
});
|
|
|
|
test("loadLockoutState returns null for missing identifier", () => {
|
|
assert.equal(ds.loadLockoutState("no-such"), null);
|
|
});
|
|
|
|
test("saveLockoutState with null lockedUntil", async () => {
|
|
await resetStorage();
|
|
ds.saveLockoutState("not-locked", { attempts: [], lockedUntil: null });
|
|
const loaded = ds.loadLockoutState("not-locked");
|
|
assert.ok(loaded !== null);
|
|
assert.deepEqual(loaded.attempts, []);
|
|
assert.equal(loaded.lockedUntil, null);
|
|
});
|
|
|
|
test("deleteLockoutState removes state", async () => {
|
|
await resetStorage();
|
|
ds.saveLockoutState("del-lock", { attempts: [1], lockedUntil: null });
|
|
ds.deleteLockoutState("del-lock");
|
|
assert.equal(ds.loadLockoutState("del-lock"), null);
|
|
});
|
|
|
|
test("loadAllLockedIdentifiers returns only currently locked", async () => {
|
|
await resetStorage();
|
|
ds.saveLockoutState("locked-now", { attempts: [1], lockedUntil: Date.now() + 3600000 });
|
|
ds.saveLockoutState("expired", { attempts: [1], lockedUntil: Date.now() - 3600000 });
|
|
ds.saveLockoutState("no-lock", { attempts: [], lockedUntil: null });
|
|
|
|
const locked = ds.loadAllLockedIdentifiers();
|
|
assert.equal(locked.length, 1);
|
|
assert.equal(locked[0].identifier, "locked-now");
|
|
});
|
|
|
|
// ──────────────── Circuit Breakers ────────────────
|
|
|
|
test("saveCircuitBreakerState and loadCircuitBreakerState round-trip", async () => {
|
|
await resetStorage();
|
|
ds.saveCircuitBreakerState("cb-1", {
|
|
state: "OPEN",
|
|
failureCount: 5,
|
|
lastFailureTime: 1000,
|
|
options: { timeout: 30000 },
|
|
});
|
|
|
|
const loaded = ds.loadCircuitBreakerState("cb-1");
|
|
assert.ok(loaded !== null);
|
|
assert.equal(loaded.state, "OPEN");
|
|
assert.equal(loaded.failureCount, 5);
|
|
assert.equal(loaded.lastFailureTime, 1000);
|
|
assert.deepEqual(loaded.options, { timeout: 30000 });
|
|
});
|
|
|
|
test("loadCircuitBreakerState returns null for missing name", () => {
|
|
assert.equal(ds.loadCircuitBreakerState("no-such"), null);
|
|
});
|
|
|
|
test("saveCircuitBreakerState without options", async () => {
|
|
await resetStorage();
|
|
ds.saveCircuitBreakerState("cb-simple", {
|
|
state: "CLOSED",
|
|
failureCount: 0,
|
|
lastFailureTime: null,
|
|
});
|
|
const loaded = ds.loadCircuitBreakerState("cb-simple");
|
|
assert.ok(loaded !== null);
|
|
assert.equal(loaded.state, "CLOSED");
|
|
assert.equal(loaded.failureCount, 0);
|
|
assert.equal(loaded.lastFailureTime, null);
|
|
assert.equal(loaded.options, null);
|
|
});
|
|
|
|
test("loadAllCircuitBreakerStates returns all", async () => {
|
|
await resetStorage();
|
|
ds.saveCircuitBreakerState("cb-a", { state: "HALF_OPEN", failureCount: 2, lastFailureTime: 500 });
|
|
ds.saveCircuitBreakerState("cb-b", { state: "CLOSED", failureCount: 0, lastFailureTime: null });
|
|
|
|
const all = ds.loadAllCircuitBreakerStates();
|
|
assert.equal(all.length, 2);
|
|
const names = all.map((r: any) => r.name).sort();
|
|
assert.deepEqual(names, ["cb-a", "cb-b"]);
|
|
});
|
|
|
|
test("deleteCircuitBreakerState removes state", async () => {
|
|
await resetStorage();
|
|
ds.saveCircuitBreakerState("del-cb", { state: "OPEN", failureCount: 1, lastFailureTime: 100 });
|
|
ds.deleteCircuitBreakerState("del-cb");
|
|
assert.equal(ds.loadCircuitBreakerState("del-cb"), null);
|
|
});
|
|
|
|
test("deleteAllCircuitBreakerStates clears everything", async () => {
|
|
await resetStorage();
|
|
ds.saveCircuitBreakerState("a", { state: "OPEN", failureCount: 1, lastFailureTime: 100 });
|
|
ds.saveCircuitBreakerState("b", { state: "CLOSED", failureCount: 0, lastFailureTime: null });
|
|
ds.deleteAllCircuitBreakerStates();
|
|
assert.deepEqual(ds.loadAllCircuitBreakerStates(), []);
|
|
});
|