381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import Database from "better-sqlite3";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
|
|
function assertAlmostEqual(actual, expected, epsilon = 1e-9, message = "") {
|
|
assert.ok(
|
|
Math.abs(actual - expected) <= epsilon,
|
|
message || `expected ${actual} to be within ${epsilon} of ${expected}`
|
|
);
|
|
}
|
|
|
|
// ─── Test Setup: Single temp dir for whole file (core caches DATA_DIR at first import) ────────
|
|
|
|
const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-"));
|
|
const originalDataDir = process.env.DATA_DIR;
|
|
process.env.DATA_DIR = fileTmpDir;
|
|
|
|
async function removeStorageFiles(dir) {
|
|
const storage = path.join(dir, "storage.sqlite");
|
|
try {
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
core.resetDbInstance();
|
|
} catch {
|
|
/* core may not be loaded yet */
|
|
}
|
|
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
const p = storage + suffix;
|
|
try {
|
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await removeStorageFiles(fileTmpDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
core.resetDbInstance();
|
|
});
|
|
|
|
after(() => {
|
|
process.env.DATA_DIR = originalDataDir;
|
|
if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── Fallback Policy Tests ────────────────────────
|
|
|
|
describe("fallbackPolicy persistence", () => {
|
|
it("should register and resolve a fallback chain", async () => {
|
|
const { registerFallback, resolveFallbackChain, hasFallback, resetAllFallbacks } =
|
|
await import("../../src/domain/fallbackPolicy.ts");
|
|
|
|
resetAllFallbacks();
|
|
|
|
registerFallback("gpt-4o", [
|
|
{ provider: "openai", priority: 0, enabled: true },
|
|
{ provider: "anthropic", priority: 1, enabled: true },
|
|
{ provider: "disabled-one", priority: 2, enabled: false },
|
|
]);
|
|
|
|
assert.ok(hasFallback("gpt-4o"));
|
|
|
|
const chain = resolveFallbackChain("gpt-4o");
|
|
assert.equal(chain.length, 2); // disabled-one excluded
|
|
assert.equal(chain[0].provider, "openai");
|
|
assert.equal(chain[1].provider, "anthropic");
|
|
|
|
resetAllFallbacks();
|
|
});
|
|
|
|
it("should resolve with exclusions", async () => {
|
|
const { registerFallback, resolveFallbackChain, resetAllFallbacks } =
|
|
await import("../../src/domain/fallbackPolicy.ts");
|
|
|
|
resetAllFallbacks();
|
|
|
|
registerFallback("claude-3", [
|
|
{ provider: "anthropic", priority: 0 },
|
|
{ provider: "openai", priority: 1 },
|
|
]);
|
|
|
|
const chain = resolveFallbackChain("claude-3", ["anthropic"]);
|
|
assert.equal(chain.length, 1);
|
|
assert.equal(chain[0].provider, "openai");
|
|
|
|
resetAllFallbacks();
|
|
});
|
|
|
|
it("should get next fallback correctly", async () => {
|
|
const { registerFallback, getNextFallback, resetAllFallbacks } =
|
|
await import("../../src/domain/fallbackPolicy.ts");
|
|
|
|
resetAllFallbacks();
|
|
|
|
registerFallback("test-model", [
|
|
{ provider: "p1", priority: 0 },
|
|
{ provider: "p2", priority: 1 },
|
|
]);
|
|
|
|
assert.equal(getNextFallback("test-model"), "p1");
|
|
assert.equal(getNextFallback("test-model", ["p1"]), "p2");
|
|
assert.equal(getNextFallback("test-model", ["p1", "p2"]), null);
|
|
|
|
// Non-existing model
|
|
assert.equal(getNextFallback("no-model"), null);
|
|
|
|
resetAllFallbacks();
|
|
});
|
|
|
|
it("should remove fallback chain", async () => {
|
|
const { registerFallback, removeFallback, hasFallback, resetAllFallbacks } =
|
|
await import("../../src/domain/fallbackPolicy.ts");
|
|
|
|
resetAllFallbacks();
|
|
|
|
registerFallback("gpt-4", [{ provider: "openai" }]);
|
|
assert.ok(hasFallback("gpt-4"));
|
|
|
|
removeFallback("gpt-4");
|
|
assert.ok(!hasFallback("gpt-4"));
|
|
|
|
resetAllFallbacks();
|
|
});
|
|
|
|
it("should get all fallback chains", async () => {
|
|
const { registerFallback, getAllFallbackChains, resetAllFallbacks } =
|
|
await import("../../src/domain/fallbackPolicy.ts");
|
|
|
|
resetAllFallbacks();
|
|
|
|
registerFallback("m1", [{ provider: "p1" }]);
|
|
registerFallback("m2", [{ provider: "p2" }]);
|
|
|
|
const all = getAllFallbackChains();
|
|
assert.ok("m1" in all);
|
|
assert.ok("m2" in all);
|
|
|
|
resetAllFallbacks();
|
|
});
|
|
});
|
|
|
|
// ─── Cost Rules Tests ────────────────────────
|
|
|
|
describe("costRules persistence", () => {
|
|
it("should set and check budget", async () => {
|
|
const { setBudget, getBudget, checkBudget, resetCostData } =
|
|
await import("../../src/domain/costRules.ts");
|
|
|
|
resetCostData();
|
|
|
|
setBudget("key1", { dailyLimitUsd: 10 });
|
|
const budget = getBudget("key1");
|
|
assert.equal(budget.dailyLimitUsd, 10);
|
|
assert.equal(budget.warningThreshold, 0.8);
|
|
|
|
const check = checkBudget("key1");
|
|
assert.ok(check.allowed);
|
|
assert.equal(check.dailyLimit, 10);
|
|
|
|
resetCostData();
|
|
});
|
|
|
|
it("should record cost and check daily total", async () => {
|
|
const { setBudget, recordCost, getDailyTotal, checkBudget, resetCostData } =
|
|
await import("../../src/domain/costRules.ts");
|
|
|
|
resetCostData();
|
|
|
|
setBudget("key2", { dailyLimitUsd: 5 });
|
|
recordCost("key2", 3.5);
|
|
recordCost("key2", 1.0);
|
|
|
|
const total = getDailyTotal("key2");
|
|
assertAlmostEqual(total, 4.5, 1e-9, `daily total ${total} should equal 4.5 (3.5 + 1.0)`);
|
|
|
|
// Should still be allowed
|
|
const check = checkBudget("key2", 0);
|
|
assert.ok(check.allowed);
|
|
|
|
// Should be denied with additional cost
|
|
const checkOver = checkBudget("key2", 1.0);
|
|
assert.ok(!checkOver.allowed);
|
|
|
|
resetCostData();
|
|
});
|
|
|
|
it("should return allowed=true when no budget set", async () => {
|
|
const { checkBudget, resetCostData } = await import("../../src/domain/costRules.ts");
|
|
|
|
resetCostData();
|
|
|
|
const check = checkBudget("no-budget-key");
|
|
assert.ok(check.allowed);
|
|
assert.equal(check.dailyLimit, 0);
|
|
|
|
resetCostData();
|
|
});
|
|
|
|
it("should get cost summary", async () => {
|
|
const { setBudget, recordCost, getCostSummary, resetCostData } =
|
|
await import("../../src/domain/costRules.ts");
|
|
|
|
resetCostData();
|
|
|
|
setBudget("key3", { dailyLimitUsd: 100 });
|
|
recordCost("key3", 1.5);
|
|
recordCost("key3", 2.5);
|
|
|
|
const summary = getCostSummary("key3");
|
|
assertAlmostEqual(
|
|
summary.dailyTotal,
|
|
4.0,
|
|
1e-9,
|
|
`dailyTotal ${summary.dailyTotal} should equal 4.0 (1.5 + 2.5)`
|
|
);
|
|
assertAlmostEqual(
|
|
summary.monthlyTotal,
|
|
4.0,
|
|
1e-9,
|
|
`monthlyTotal ${summary.monthlyTotal} should equal 4.0`
|
|
);
|
|
assert.equal(summary.budget.dailyLimitUsd, 100);
|
|
|
|
resetCostData();
|
|
});
|
|
});
|
|
|
|
// ─── Lockout Policy Tests ────────────────────────
|
|
|
|
describe("lockoutPolicy persistence", () => {
|
|
it("should track failed attempts and trigger lockout", async () => {
|
|
const { recordFailedAttempt, checkLockout, recordSuccess } =
|
|
await import("../../src/domain/lockoutPolicy.ts");
|
|
|
|
const id = "test-ip-" + Date.now();
|
|
const config = { maxAttempts: 3, lockoutDurationMs: 5000, attemptWindowMs: 10000 };
|
|
|
|
// First attempts should not lock
|
|
let result = recordFailedAttempt(id, config);
|
|
assert.ok(!result.locked);
|
|
|
|
result = recordFailedAttempt(id, config);
|
|
assert.ok(!result.locked);
|
|
|
|
// Third attempt triggers lockout
|
|
result = recordFailedAttempt(id, config);
|
|
assert.ok(result.locked);
|
|
assert.ok(result.remainingMs > 0);
|
|
|
|
// Check lockout
|
|
const lockCheck = checkLockout(id, config);
|
|
assert.ok(lockCheck.locked);
|
|
|
|
// Clean up
|
|
recordSuccess(id);
|
|
});
|
|
|
|
it("should unlock after success", async () => {
|
|
const { recordFailedAttempt, recordSuccess, checkLockout } =
|
|
await import("../../src/domain/lockoutPolicy.ts");
|
|
|
|
const id = "test-unlock-" + Date.now();
|
|
const config = { maxAttempts: 3, lockoutDurationMs: 5000, attemptWindowMs: 10000 };
|
|
|
|
recordFailedAttempt(id, config);
|
|
recordFailedAttempt(id, config);
|
|
|
|
recordSuccess(id);
|
|
|
|
const check = checkLockout(id, config);
|
|
assert.ok(!check.locked);
|
|
});
|
|
|
|
it("should force-unlock", async () => {
|
|
const { recordFailedAttempt, forceUnlock, checkLockout } =
|
|
await import("../../src/domain/lockoutPolicy.ts");
|
|
|
|
const id = "test-force-" + Date.now();
|
|
const config = { maxAttempts: 2, lockoutDurationMs: 60000, attemptWindowMs: 60000 };
|
|
|
|
recordFailedAttempt(id, config);
|
|
recordFailedAttempt(id, config);
|
|
|
|
const lockCheck = checkLockout(id, config);
|
|
assert.ok(lockCheck.locked);
|
|
|
|
forceUnlock(id);
|
|
|
|
const afterUnlock = checkLockout(id, config);
|
|
assert.ok(!afterUnlock.locked);
|
|
});
|
|
});
|
|
|
|
// ─── Circuit Breaker Tests ────────────────────────
|
|
|
|
describe("circuitBreaker persistence", () => {
|
|
it("should open after threshold failures", async () => {
|
|
const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts");
|
|
|
|
const cb = new CircuitBreaker("test-cb-" + Date.now(), { failureThreshold: 3 });
|
|
assert.equal(cb.state, STATE.CLOSED);
|
|
|
|
// Simulate failures
|
|
for (let i = 0; i < 3; i++) {
|
|
try {
|
|
await cb.execute(() => Promise.reject(new Error("fail")));
|
|
} catch {
|
|
// expected
|
|
}
|
|
}
|
|
|
|
assert.equal(cb.state, STATE.OPEN);
|
|
assert.ok(!cb.canExecute());
|
|
});
|
|
|
|
it("should reset correctly", async () => {
|
|
const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts");
|
|
|
|
const cb = new CircuitBreaker("test-reset-" + Date.now(), { failureThreshold: 2 });
|
|
|
|
try {
|
|
await cb.execute(() => Promise.reject(new Error("fail")));
|
|
} catch {}
|
|
try {
|
|
await cb.execute(() => Promise.reject(new Error("fail")));
|
|
} catch {}
|
|
|
|
assert.equal(cb.state, STATE.OPEN);
|
|
|
|
cb.reset();
|
|
assert.equal(cb.state, STATE.CLOSED);
|
|
assert.equal(cb.failureCount, 0);
|
|
assert.ok(cb.canExecute());
|
|
});
|
|
|
|
it("should close on success after half-open", async () => {
|
|
const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts");
|
|
|
|
const cb = new CircuitBreaker("test-halfopen-" + Date.now(), {
|
|
failureThreshold: 2,
|
|
resetTimeout: 10, // 10ms for test speed
|
|
});
|
|
|
|
// Open the circuit
|
|
try {
|
|
await cb.execute(() => Promise.reject(new Error("fail")));
|
|
} catch {}
|
|
try {
|
|
await cb.execute(() => Promise.reject(new Error("fail")));
|
|
} catch {}
|
|
assert.equal(cb.state, STATE.OPEN);
|
|
|
|
// Wait for resetTimeout
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// Should transition to HALF_OPEN and succeed
|
|
const result = await cb.execute(() => Promise.resolve("ok"));
|
|
assert.equal(result, "ok");
|
|
assert.equal(cb.state, STATE.CLOSED);
|
|
});
|
|
|
|
it("registry should return statuses", async () => {
|
|
const { getCircuitBreaker, getAllCircuitBreakerStatuses } =
|
|
await import("../../src/shared/utils/circuitBreaker.ts");
|
|
|
|
const name = "reg-test-" + Date.now();
|
|
const cb = getCircuitBreaker(name, { failureThreshold: 5 });
|
|
assert.ok(cb);
|
|
|
|
const statuses = getAllCircuitBreakerStatuses();
|
|
const found = statuses.find((s) => s.name === name);
|
|
assert.ok(found);
|
|
assert.equal(found.state, "CLOSED");
|
|
});
|
|
});
|