chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Unit coverage for the api_keys DB layer — focused on the `scopes` column
|
||||
* and the audit-event emission contract for privileged scope changes.
|
||||
*
|
||||
* The fixtures here intentionally mirror tests/unit/api-auth.test.ts so the
|
||||
* two suites share the same bootstrap shape (isolated DATA_DIR, fresh DB per
|
||||
* test, env reset).
|
||||
*/
|
||||
|
||||
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-api-keys-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const compliance = await import("../../../src/lib/compliance/index.ts");
|
||||
const { hasManageScope } = await import("../../../src/shared/constants/managementScopes.ts");
|
||||
|
||||
const MACHINE_ID = "machine1234567890";
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createApiKey + scopes round-trip
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("createApiKey persists scopes to the api_keys row", async () => {
|
||||
const created = await apiKeysDb.createApiKey("with-manage", MACHINE_ID, ["manage"]);
|
||||
assert.ok(created.id);
|
||||
assert.ok(created.key);
|
||||
assert.deepEqual(created.scopes, ["manage"]);
|
||||
|
||||
// Verify the row hit the DB by reading raw column.
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined };
|
||||
};
|
||||
const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id);
|
||||
assert.equal(row?.scopes, JSON.stringify(["manage"]));
|
||||
});
|
||||
|
||||
test("createApiKey with default scopes writes an empty JSON array", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-scope", MACHINE_ID);
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined };
|
||||
};
|
||||
const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id);
|
||||
assert.equal(row?.scopes, "[]");
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns the scopes for a key created with manage", async () => {
|
||||
const created = await apiKeysDb.createApiKey("metadata-readback", MACHINE_ID, ["manage"]);
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"]);
|
||||
assert.equal(hasManageScope(meta!.scopes), true);
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns an empty scopes array for a key created without scopes", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-manage", MACHINE_ID);
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, []);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Legacy NULL scopes (pre-migration-032 row simulation)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("legacy rows with NULL scopes parse to an empty array and never hold manage", async () => {
|
||||
const created = await apiKeysDb.createApiKey("legacy-null", MACHINE_ID);
|
||||
// Simulate a pre-migration row by force-NULL on the scopes column.
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { run: (...args: unknown[]) => unknown };
|
||||
};
|
||||
db.prepare("UPDATE api_keys SET scopes = NULL WHERE id = ?").run(created.id);
|
||||
apiKeysDb.clearApiKeyCaches();
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, []);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// updateApiKeyPermissions — audit events for scope changes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("updateApiKeyPermissions granting manage emits apiKey.scopes.grant", async () => {
|
||||
const created = await apiKeysDb.createApiKey("for-grant", MACHINE_ID);
|
||||
|
||||
const before = compliance.getAuditLog({ limit: 100 });
|
||||
const beforeGrant = before.filter(
|
||||
(e) => e.action === "apiKey.scopes.grant" && e.target === created.id
|
||||
);
|
||||
assert.equal(beforeGrant.length, 0);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const grants = after.filter((e) => e.action === "apiKey.scopes.grant" && e.target === created.id);
|
||||
assert.equal(grants.length, 1, "expected exactly one grant audit event");
|
||||
|
||||
// Confirm the round-trip — manage should now be on the key.
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.equal(hasManageScope(meta!.scopes), true);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions revoking manage emits apiKey.scopes.revoke", async () => {
|
||||
const created = await apiKeysDb.createApiKey("for-revoke", MACHINE_ID, ["manage"]);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: [] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const revokes = after.filter(
|
||||
(e) => e.action === "apiKey.scopes.revoke" && e.target === created.id
|
||||
);
|
||||
assert.equal(revokes.length, 1, "expected exactly one revoke audit event");
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions setting same manage scope does not emit duplicate audit events", async () => {
|
||||
const created = await apiKeysDb.createApiKey("idempotent-manage", MACHINE_ID, ["manage"]);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const scopeEvents = after.filter(
|
||||
(e) =>
|
||||
(e.action === "apiKey.scopes.grant" ||
|
||||
e.action === "apiKey.scopes.revoke" ||
|
||||
e.action === "apiKey.scopes.update") &&
|
||||
e.target === created.id
|
||||
);
|
||||
assert.equal(
|
||||
scopeEvents.length,
|
||||
0,
|
||||
"no-op scope update should not emit grant/revoke/update events"
|
||||
);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions changing non-manage scopes emits apiKey.scopes.update", async () => {
|
||||
const created = await apiKeysDb.createApiKey("non-manage-update", MACHINE_ID, []);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["read:logs"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const updates = after.filter(
|
||||
(e) => e.action === "apiKey.scopes.update" && e.target === created.id
|
||||
);
|
||||
assert.equal(updates.length, 1, "expected exactly one non-manage scope update event");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Regression — scopes and ban state are orthogonal (T-008)
|
||||
//
|
||||
// The Permissions modal previously had two chips bound to `isBanned` (one
|
||||
// labelled "Management API Access" with manage-scope copy). A user toggling
|
||||
// it expected manage-scope grant; instead it flipped the ban flag. These
|
||||
// tests guard against the inverse cross-wire ever returning: updating scopes
|
||||
// must not touch isBanned, and toggling isBanned must not touch scopes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("updating scopes to manage leaves isBanned untouched", async () => {
|
||||
const created = await apiKeysDb.createApiKey("banned-then-manage", MACHINE_ID, []);
|
||||
const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true });
|
||||
assert.equal(banOk, true);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"]);
|
||||
assert.equal(meta!.isBanned, true, "ban flag must survive a scopes-only update");
|
||||
});
|
||||
|
||||
test("toggling isBanned does not touch scopes", async () => {
|
||||
const created = await apiKeysDb.createApiKey("manage-then-ban", MACHINE_ID, ["manage"]);
|
||||
|
||||
const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true });
|
||||
assert.equal(banOk, true);
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"], "scopes must survive a ban-only update");
|
||||
assert.equal(meta!.isBanned, true);
|
||||
|
||||
const unbanOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: false });
|
||||
assert.equal(unbanOk, true);
|
||||
|
||||
const meta2 = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta2);
|
||||
assert.deepEqual(meta2!.scopes, ["manage"], "scopes must survive an unban update");
|
||||
assert.equal(meta2!.isBanned, false);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions without scopes field does not emit any scope audit event", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-scope-change", MACHINE_ID);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { name: "renamed" });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const scopeEvents = after.filter(
|
||||
(e) =>
|
||||
(e.action === "apiKey.scopes.grant" ||
|
||||
e.action === "apiKey.scopes.revoke" ||
|
||||
e.action === "apiKey.scopes.update") &&
|
||||
e.target === created.id
|
||||
);
|
||||
assert.equal(scopeEvents.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "omniroute-rt-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
const {
|
||||
insertCompressionRunTelemetryRow,
|
||||
getCompressionRunTelemetrySummary,
|
||||
} = await import("../../../src/lib/db/compressionRunTelemetry.ts");
|
||||
const { getDbInstance } = core;
|
||||
|
||||
describe("compressionRunTelemetry", () => {
|
||||
beforeEach(() => {
|
||||
const db = getDbInstance();
|
||||
db.exec("DROP TABLE IF EXISTS compression_run_telemetry");
|
||||
});
|
||||
|
||||
it("persists a run record and summarizes savings + applied styles", () => {
|
||||
insertCompressionRunTelemetryRow({
|
||||
requestId: "req-1",
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
source: "active-profile",
|
||||
tokensBefore: 1000,
|
||||
tokensAfter: 700,
|
||||
ratio: 0.7,
|
||||
outputStyles: [{ id: "terse-prose", level: "full" }],
|
||||
outputTokens: 320,
|
||||
});
|
||||
insertCompressionRunTelemetryRow({
|
||||
requestId: "req-2",
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
source: "default",
|
||||
tokensBefore: 500,
|
||||
tokensAfter: 500,
|
||||
ratio: 1,
|
||||
outputStyleBypass: "security_warning",
|
||||
});
|
||||
|
||||
const summary = getCompressionRunTelemetrySummary();
|
||||
assert.equal(summary.totalRuns, 2);
|
||||
assert.equal(summary.totalTokensSaved, 300); // (1000-700) + (500-500)
|
||||
assert.equal(summary.runsWithStyles, 1);
|
||||
assert.equal(summary.bypassCount, 1);
|
||||
assert.deepEqual(summary.appliedStyleCounts, { "terse-prose": 1 });
|
||||
});
|
||||
|
||||
it("never throws on a malformed row; outputStyles is optional", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
insertCompressionRunTelemetryRow({
|
||||
requestId: "req-3",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
source: "off",
|
||||
tokensBefore: 0,
|
||||
tokensAfter: 0,
|
||||
ratio: 0,
|
||||
})
|
||||
);
|
||||
assert.equal(getCompressionRunTelemetrySummary().totalRuns, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TDD for F4.1 — recordContextEditingTelemetry: writes a compression_analytics
|
||||
* row under engine "context-editing" for the tokens the provider cleared via
|
||||
* server-side context editing.
|
||||
*
|
||||
* DB isolation pattern mirrors tests/unit/db/per-engine-analytics.test.ts:
|
||||
* - Temp DATA_DIR, resetDbInstance() before/after, restore in test.after().
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/db/context-editing-telemetry-record.test.ts
|
||||
*/
|
||||
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-ctx-edit-telemetry-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const {
|
||||
recordContextEditingTelemetry,
|
||||
getCompressionAnalyticsSummary,
|
||||
getLatestCompressionAnalyticsRun,
|
||||
} = await import("../../../src/lib/db/compressionAnalytics.ts");
|
||||
|
||||
function resetDb(): void {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
test("records a context-editing row reflected in byEngine analytics", () => {
|
||||
recordContextEditingTelemetry(
|
||||
"req-1",
|
||||
{ editCount: 1, clearedInputTokens: 50000, clearedToolUses: 8 },
|
||||
"claude"
|
||||
);
|
||||
const summary = getCompressionAnalyticsSummary();
|
||||
assert.ok(summary.byEngine["context-editing"], "byEngine has a context-editing bucket");
|
||||
assert.equal(summary.byEngine["context-editing"].count, 1);
|
||||
assert.equal(summary.byEngine["context-editing"].tokensSaved, 50000);
|
||||
assert.equal(summary.totalTokensSaved, 50000);
|
||||
});
|
||||
|
||||
test("is a no-op when nothing was cleared (clearedInputTokens <= 0)", () => {
|
||||
recordContextEditingTelemetry("req-2", {
|
||||
editCount: 1,
|
||||
clearedInputTokens: 0,
|
||||
clearedToolUses: 0,
|
||||
});
|
||||
const summary = getCompressionAnalyticsSummary();
|
||||
assert.equal(summary.totalRequests, 0, "no row written");
|
||||
assert.equal(summary.byEngine["context-editing"], undefined);
|
||||
});
|
||||
|
||||
test("is a no-op for null/undefined telemetry", () => {
|
||||
recordContextEditingTelemetry("req-3", null as never);
|
||||
recordContextEditingTelemetry("req-4", undefined as never);
|
||||
assert.equal(getCompressionAnalyticsSummary().totalRequests, 0);
|
||||
});
|
||||
|
||||
test("uses a suffixed request_id so it never collides with the usage-receipt UPDATE", () => {
|
||||
recordContextEditingTelemetry(
|
||||
"abc123",
|
||||
{ editCount: 1, clearedInputTokens: 1000, clearedToolUses: 1 },
|
||||
"claude"
|
||||
);
|
||||
const latest = getLatestCompressionAnalyticsRun();
|
||||
assert.ok(latest);
|
||||
assert.equal(latest.engine, "context-editing");
|
||||
assert.equal(latest.mode, "context-editing");
|
||||
assert.notEqual(latest.request_id, "abc123", "must not reuse the raw request id");
|
||||
assert.ok(
|
||||
typeof latest.request_id === "string" && latest.request_id.startsWith("abc123"),
|
||||
"stays traceable to the originating request"
|
||||
);
|
||||
});
|
||||
|
||||
test("aggregates multiple context-editing rows", () => {
|
||||
recordContextEditingTelemetry("r1", {
|
||||
editCount: 1,
|
||||
clearedInputTokens: 1000,
|
||||
clearedToolUses: 1,
|
||||
});
|
||||
recordContextEditingTelemetry("r2", {
|
||||
editCount: 2,
|
||||
clearedInputTokens: 2500,
|
||||
clearedToolUses: 3,
|
||||
});
|
||||
const summary = getCompressionAnalyticsSummary();
|
||||
assert.equal(summary.byEngine["context-editing"].count, 2);
|
||||
assert.equal(summary.byEngine["context-editing"].tokensSaved, 3500);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* TDD: setEngineInDefaultCombo + normalizePipeline (new engines).
|
||||
*
|
||||
* DB isolation pattern mirrors tests/unit/db/per-engine-analytics.test.ts:
|
||||
* - Temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
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";
|
||||
|
||||
// ─── isolated temp DB ─────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-default-combo-toggle-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const { getDefaultCompressionCombo, setEngineInDefaultCombo, getCompressionCombo } =
|
||||
await import("../../../src/lib/db/compressionCombos.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function resetDb(): void {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("Fix #1: normalizePipeline passes through new engine IDs (headroom, session-dedup, ccr, llmlingua)", () => {
|
||||
// The default combo is seeded with [rtk, caveman]. Directly update the DB
|
||||
// to include new engine IDs, then read back via getDefaultCompressionCombo
|
||||
// to verify normalizePipeline no longer strips them.
|
||||
const db = core.getDbInstance();
|
||||
|
||||
// Ensure the table is created by triggering a read first.
|
||||
const combo = getDefaultCompressionCombo();
|
||||
assert.ok(combo, "default combo should exist after table init");
|
||||
|
||||
const newPipeline = JSON.stringify([
|
||||
{ engine: "session-dedup" },
|
||||
{ engine: "ccr" },
|
||||
{ engine: "headroom" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
{ engine: "llmlingua" },
|
||||
]);
|
||||
db.prepare("UPDATE compression_combos SET pipeline = ? WHERE id = ?").run(newPipeline, combo.id);
|
||||
|
||||
const reloaded = getCompressionCombo(combo.id);
|
||||
assert.ok(reloaded, "should reload the combo");
|
||||
const engineIds = reloaded.pipeline.map((s) => s.engine);
|
||||
assert.ok(engineIds.includes("headroom"), `expected headroom in pipeline, got: ${engineIds}`);
|
||||
assert.ok(
|
||||
engineIds.includes("session-dedup"),
|
||||
`expected session-dedup in pipeline, got: ${engineIds}`
|
||||
);
|
||||
assert.ok(engineIds.includes("ccr"), `expected ccr in pipeline, got: ${engineIds}`);
|
||||
assert.ok(engineIds.includes("llmlingua"), `expected llmlingua in pipeline, got: ${engineIds}`);
|
||||
assert.equal(
|
||||
reloaded.pipeline.length,
|
||||
5,
|
||||
`expected 5 steps, got ${reloaded.pipeline.length}: ${engineIds}`
|
||||
);
|
||||
});
|
||||
|
||||
test("enabling headroom adds it to the pipeline sorted by stackPriority", () => {
|
||||
// Default pipeline is [rtk(10), caveman(20)].
|
||||
// headroom has stackPriority=15 so it should be inserted between rtk and caveman.
|
||||
const result = setEngineInDefaultCombo("headroom", true);
|
||||
assert.ok(result, "should return the updated combo");
|
||||
|
||||
const engineIds = result.pipeline.map((s) => s.engine);
|
||||
assert.ok(engineIds.includes("headroom"), "headroom should be in the pipeline");
|
||||
|
||||
const rtkIdx = engineIds.indexOf("rtk");
|
||||
const headroomIdx = engineIds.indexOf("headroom");
|
||||
const cavemanIdx = engineIds.indexOf("caveman");
|
||||
|
||||
assert.ok(rtkIdx >= 0, "rtk should be in the pipeline");
|
||||
assert.ok(headroomIdx >= 0, "headroom should be in the pipeline");
|
||||
assert.ok(cavemanIdx >= 0, "caveman should be in the pipeline");
|
||||
|
||||
assert.ok(
|
||||
rtkIdx < headroomIdx,
|
||||
`rtk(10) should come before headroom(15), got order: ${engineIds}`
|
||||
);
|
||||
assert.ok(
|
||||
headroomIdx < cavemanIdx,
|
||||
`headroom(15) should come before caveman(20), got order: ${engineIds}`
|
||||
);
|
||||
});
|
||||
|
||||
test("enabling an engine with config persists the config", () => {
|
||||
const customConfig = { minRows: 5 };
|
||||
const result = setEngineInDefaultCombo("headroom", true, customConfig);
|
||||
assert.ok(result, "should return the updated combo");
|
||||
|
||||
const headroomStep = result.pipeline.find((s) => s.engine === "headroom");
|
||||
assert.ok(headroomStep, "headroom step should be present");
|
||||
assert.deepEqual(
|
||||
headroomStep.config,
|
||||
customConfig,
|
||||
"config should be persisted on the pipeline step"
|
||||
);
|
||||
});
|
||||
|
||||
test("updating config on an already-present engine merges correctly", () => {
|
||||
// First enable headroom
|
||||
setEngineInDefaultCombo("headroom", true);
|
||||
// Then re-enable with a config — should update the existing step, not add a duplicate
|
||||
const result = setEngineInDefaultCombo("headroom", true, { minRows: 8 });
|
||||
assert.ok(result, "should return the updated combo");
|
||||
|
||||
const headroomSteps = result.pipeline.filter((s) => s.engine === "headroom");
|
||||
assert.equal(headroomSteps.length, 1, "should not duplicate the headroom step");
|
||||
assert.deepEqual(headroomSteps[0].config, { minRows: 8 });
|
||||
});
|
||||
|
||||
test("disabling an engine removes it from the pipeline", () => {
|
||||
setEngineInDefaultCombo("headroom", true);
|
||||
const before = getDefaultCompressionCombo();
|
||||
assert.ok(
|
||||
before?.pipeline.some((s) => s.engine === "headroom"),
|
||||
"headroom should be in pipeline before disabling"
|
||||
);
|
||||
|
||||
const result = setEngineInDefaultCombo("headroom", false);
|
||||
assert.ok(result, "should return the updated combo");
|
||||
assert.ok(
|
||||
!result.pipeline.some((s) => s.engine === "headroom"),
|
||||
"headroom should be removed from pipeline"
|
||||
);
|
||||
});
|
||||
|
||||
test("Fix #8: setEngineInDefaultCombo with unknown engineId returns null and does not modify the pipeline", () => {
|
||||
const before = getDefaultCompressionCombo();
|
||||
assert.ok(before, "default combo must exist");
|
||||
const originalPipeline = JSON.stringify(before.pipeline);
|
||||
|
||||
const result = setEngineInDefaultCombo("not-a-real-engine", true);
|
||||
assert.equal(result, null, "should return null for unknown engine id");
|
||||
|
||||
// The combo must be unchanged
|
||||
const after = getDefaultCompressionCombo();
|
||||
assert.ok(after, "default combo should still exist");
|
||||
assert.equal(
|
||||
JSON.stringify(after.pipeline),
|
||||
originalPipeline,
|
||||
"pipeline should be unmodified when unknown engineId is rejected"
|
||||
);
|
||||
});
|
||||
|
||||
test("Fix #2: disabling last engine produces an empty pipeline (not silently reverted to default)", () => {
|
||||
// Start with a pipeline that only has one engine by disabling everything except headroom.
|
||||
// First set a pipeline with only one known engine via a raw DB update.
|
||||
const db = core.getDbInstance();
|
||||
const combo = getDefaultCompressionCombo();
|
||||
assert.ok(combo, "default combo must exist");
|
||||
|
||||
db.prepare("UPDATE compression_combos SET pipeline = ? WHERE id = ?").run(
|
||||
JSON.stringify([{ engine: "headroom" }]),
|
||||
combo.id
|
||||
);
|
||||
|
||||
// Now disable headroom — result should be empty pipeline, not a fallback.
|
||||
const result = setEngineInDefaultCombo("headroom", false);
|
||||
assert.ok(result, "should return the updated combo");
|
||||
assert.equal(
|
||||
result.pipeline.length,
|
||||
0,
|
||||
`expected empty pipeline after disabling last engine, got: ${JSON.stringify(result.pipeline)}`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Isolate the DB into a throwaway DATA_DIR so migrations run against a fresh file.
|
||||
let tmpDataDir: string;
|
||||
let mod: typeof import("@/lib/db/discoveryResults");
|
||||
let core: typeof import("@/lib/db/core");
|
||||
|
||||
before(async () => {
|
||||
tmpDataDir = mkdtempSync(join(tmpdir(), "omniroute-discovery-"));
|
||||
process.env.DATA_DIR = tmpDataDir;
|
||||
core = await import("@/lib/db/core");
|
||||
core.resetDbInstance();
|
||||
// Touch the instance so migrations (incl. 074_discovery_results) apply.
|
||||
core.getDbInstance();
|
||||
mod = await import("@/lib/db/discoveryResults");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
if (tmpDataDir) rmSync(tmpDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("discoveryResults DB module", () => {
|
||||
test("upsert inserts a new row and returns it with an id", () => {
|
||||
const row = mod.upsertDiscoveryResult({
|
||||
providerId: "acme",
|
||||
method: "free_tier",
|
||||
authType: "none",
|
||||
feasibility: 4,
|
||||
riskLevel: "low",
|
||||
status: "pending",
|
||||
models: ["acme-large", "acme-small"],
|
||||
endpoint: "https://acme.example/api",
|
||||
});
|
||||
assert.ok(typeof row.id === "number" && row.id > 0);
|
||||
assert.equal(row.providerId, "acme");
|
||||
assert.deepEqual(row.models, ["acme-large", "acme-small"]);
|
||||
assert.equal(row.status, "pending");
|
||||
});
|
||||
|
||||
test("upsert on the same (provider, method, endpoint) updates instead of duplicating", () => {
|
||||
const first = mod.upsertDiscoveryResult({
|
||||
providerId: "beta",
|
||||
method: "web_cookie",
|
||||
authType: "cookie",
|
||||
feasibility: 3,
|
||||
riskLevel: "medium",
|
||||
status: "pending",
|
||||
endpoint: "https://beta.example/chat",
|
||||
});
|
||||
const second = mod.upsertDiscoveryResult({
|
||||
providerId: "beta",
|
||||
method: "web_cookie",
|
||||
authType: "cookie",
|
||||
feasibility: 5,
|
||||
riskLevel: "medium",
|
||||
status: "testing",
|
||||
endpoint: "https://beta.example/chat",
|
||||
});
|
||||
assert.equal(second.id, first.id);
|
||||
assert.equal(second.feasibility, 5);
|
||||
assert.equal(second.status, "testing");
|
||||
const all = mod.getDiscoveryResults("beta");
|
||||
assert.equal(all.length, 1);
|
||||
});
|
||||
|
||||
test("getDiscoveryResults filters by providerId and returns all when omitted", () => {
|
||||
const beta = mod.getDiscoveryResults("beta");
|
||||
assert.ok(beta.every((r) => r.providerId === "beta"));
|
||||
const all = mod.getDiscoveryResults();
|
||||
assert.ok(all.length >= 2);
|
||||
});
|
||||
|
||||
test("getDiscoveryResultById returns the row or null", () => {
|
||||
const created = mod.upsertDiscoveryResult({
|
||||
providerId: "gamma",
|
||||
method: "trial",
|
||||
authType: "api_key",
|
||||
feasibility: 2,
|
||||
riskLevel: "low",
|
||||
status: "pending",
|
||||
});
|
||||
const found = mod.getDiscoveryResultById(created.id!);
|
||||
assert.equal(found?.providerId, "gamma");
|
||||
assert.equal(mod.getDiscoveryResultById(999999), null);
|
||||
});
|
||||
|
||||
test("markVerified sets status=verified and stamps verified_at", () => {
|
||||
const created = mod.upsertDiscoveryResult({
|
||||
providerId: "delta",
|
||||
method: "public_api",
|
||||
authType: "api_key",
|
||||
feasibility: 5,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
});
|
||||
const updated = mod.markVerified(created.id!);
|
||||
assert.equal(updated?.status, "verified");
|
||||
assert.ok(updated?.verifiedAt);
|
||||
});
|
||||
|
||||
test("markVerified on a missing id returns null", () => {
|
||||
assert.equal(mod.markVerified(999999), null);
|
||||
});
|
||||
|
||||
test("deleteDiscoveryResult removes the row and returns true, false if absent", () => {
|
||||
const created = mod.upsertDiscoveryResult({
|
||||
providerId: "epsilon",
|
||||
method: "free_tier",
|
||||
authType: "none",
|
||||
feasibility: 1,
|
||||
riskLevel: "none",
|
||||
status: "pending",
|
||||
});
|
||||
assert.equal(mod.deleteDiscoveryResult(created.id!), true);
|
||||
assert.equal(mod.getDiscoveryResultById(created.id!), null);
|
||||
assert.equal(mod.deleteDiscoveryResult(created.id!), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery service reporter delegation", () => {
|
||||
test("persistDiscoveryResult writes through and getDiscoveryResults reads it back", async () => {
|
||||
const svc = await import("@/lib/discovery/index");
|
||||
const saved = svc.persistDiscoveryResult({
|
||||
providerId: "zeta",
|
||||
method: "public_api",
|
||||
authType: "api_key",
|
||||
feasibility: 5,
|
||||
riskLevel: "none",
|
||||
status: "verified",
|
||||
models: ["zeta-1"],
|
||||
});
|
||||
assert.ok(saved.id! > 0);
|
||||
const read = svc.getDiscoveryResults("zeta");
|
||||
assert.equal(read.length, 1);
|
||||
assert.equal(read[0].providerId, "zeta");
|
||||
assert.deepEqual(read[0].models, ["zeta-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
// Chave determinística p/ exercitar o caminho real de auth-tag (sem ela o decrypt
|
||||
// curto-circuita no branch "no key"). Definida ANTES do import — a derivação de chave
|
||||
// é cacheada lazy no primeiro uso.
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "unit-test-storage-encryption-key-0123456789";
|
||||
|
||||
const { decrypt } = await import("../../../src/lib/db/encryption.ts");
|
||||
|
||||
// Contrato atual (hardening): falha de decrypt → null + log, NUNCA o ciphertext cru
|
||||
// (devolver o blob criptografado vazaria o dado cifrado para UI/API). Este arquivo era
|
||||
// um órfão de glob (nunca rodou em CI) e codificava o contrato antigo pré-hardening —
|
||||
// alinhado ao comportamento real/shipped em 2026-07 (plano mestre testes+CI, QW-c).
|
||||
test("decrypt() with invalid auth tag should not crash and return null", () => {
|
||||
const invalidCiphertext = "enc:v1:0000:0000:0000";
|
||||
const result = decrypt(invalidCiphertext);
|
||||
|
||||
assert.strictEqual(result, null, "Failed auth-tag decrypt must return null (never the raw blob)");
|
||||
});
|
||||
|
||||
test("decrypt() with malformed ciphertext should not crash and return null", () => {
|
||||
const malformed = "enc:v1:invalid";
|
||||
const result = decrypt(malformed);
|
||||
|
||||
assert.strictEqual(result, null, "Malformed encrypted value must return null");
|
||||
});
|
||||
|
||||
test("decrypt() with null should return null", () => {
|
||||
const result = decrypt(null);
|
||||
assert.strictEqual(result, null, "Should return null for null input");
|
||||
});
|
||||
|
||||
test("decrypt() with undefined should return undefined", () => {
|
||||
const result = decrypt(undefined);
|
||||
assert.strictEqual(result, undefined, "Should return undefined for undefined input");
|
||||
});
|
||||
|
||||
test("decrypt() with non-encrypted string should return as-is", () => {
|
||||
const plaintext = "this-is-not-encrypted";
|
||||
const result = decrypt(plaintext);
|
||||
assert.strictEqual(result, plaintext, "Should return plaintext unchanged");
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Tests for migration 071 — extend version_manager for embedded services.
|
||||
*
|
||||
* Verifies:
|
||||
* - 3 new columns are added (logs_buffer_path, provider_expose, last_sync_at)
|
||||
* - 9router seed row inserted with expected defaults
|
||||
* - Migration is idempotent (apply-twice → no error, no double seed)
|
||||
* - Existing data survives the migration
|
||||
* - getServiceRow / updateServiceField helpers work correctly
|
||||
*/
|
||||
|
||||
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-migration-071-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const versionManager = await import("../../../src/lib/db/versionManager.ts");
|
||||
|
||||
async function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration 071 — adds 3 new columns to version_manager", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const columns = (
|
||||
db.prepare("PRAGMA table_info(version_manager)").all() as Array<{ name: string }>
|
||||
).map((c) => c.name);
|
||||
|
||||
assert.ok(columns.includes("logs_buffer_path"), "logs_buffer_path column missing");
|
||||
assert.ok(columns.includes("provider_expose"), "provider_expose column missing");
|
||||
assert.ok(columns.includes("last_sync_at"), "last_sync_at column missing");
|
||||
});
|
||||
|
||||
test("migration 071 — seeds 9router row with expected defaults", async () => {
|
||||
const row = await versionManager.getServiceRow("9router");
|
||||
|
||||
assert.ok(row !== null, "9router row should exist after migration");
|
||||
assert.equal(row.tool, "9router");
|
||||
assert.equal(row.status, "not_installed");
|
||||
assert.equal(row.port, 20130);
|
||||
assert.equal(row.autoStart, false);
|
||||
assert.equal(row.autoUpdate, true);
|
||||
assert.equal(row.providerExpose, true);
|
||||
assert.equal(row.pid, null);
|
||||
assert.equal(row.logsBufferPath, null);
|
||||
assert.equal(row.lastSyncAt, null);
|
||||
});
|
||||
|
||||
test("migration 071 — idempotent: applying twice produces no error and no double seed", async () => {
|
||||
const db = core.getDbInstance();
|
||||
|
||||
// Apply the migration SQL manually a second time (simulates re-run).
|
||||
// The runner catches "duplicate column name" automatically; here we verify
|
||||
// INSERT OR IGNORE prevents a duplicate 9router row.
|
||||
assert.doesNotThrow(() => {
|
||||
db.exec(`INSERT OR IGNORE INTO version_manager
|
||||
(tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES
|
||||
('9router', 'not_installed', 20130, 0, 1, 1)`);
|
||||
});
|
||||
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM version_manager WHERE tool = '9router'")
|
||||
.all() as unknown[];
|
||||
assert.equal(rows.length, 1, "Should be exactly 1 9router row after double-seed attempt");
|
||||
});
|
||||
|
||||
test("migration 071 — existing CLIProxyAPI data survives the migration", async () => {
|
||||
const db = core.getDbInstance();
|
||||
|
||||
// Simulate a pre-existing cliproxyapi row (it's seeded by T-11, not this migration).
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update)
|
||||
VALUES ('cliproxyapi', 'stopped', 8317, 1, 1)`
|
||||
).run();
|
||||
|
||||
const row = await versionManager.getServiceRow("cliproxyapi");
|
||||
assert.ok(row !== null, "cliproxyapi row should still exist");
|
||||
assert.equal(row.status, "stopped");
|
||||
assert.equal(row.port, 8317);
|
||||
assert.equal(row.providerExpose, false, "CLIProxyAPI provider_expose should default to 0");
|
||||
});
|
||||
|
||||
test("getServiceRow — returns null for unknown tool", async () => {
|
||||
const row = await versionManager.getServiceRow("nonexistent-tool");
|
||||
assert.equal(row, null);
|
||||
});
|
||||
|
||||
test("updateServiceField — updates a whitelisted field", async () => {
|
||||
const updated = await versionManager.updateServiceField(
|
||||
"9router",
|
||||
"logsBufferPath",
|
||||
"/tmp/ring.log"
|
||||
);
|
||||
assert.ok(updated !== null);
|
||||
assert.equal(updated.logsBufferPath, "/tmp/ring.log");
|
||||
|
||||
const fetched = await versionManager.getServiceRow("9router");
|
||||
assert.equal(fetched?.logsBufferPath, "/tmp/ring.log");
|
||||
});
|
||||
|
||||
test("updateServiceField — updates providerExpose boolean → stored as INTEGER", async () => {
|
||||
const updated = await versionManager.updateServiceField("9router", "providerExpose", false);
|
||||
assert.ok(updated !== null);
|
||||
assert.equal(updated.providerExpose, false);
|
||||
|
||||
const fetched = await versionManager.getServiceRow("9router");
|
||||
assert.equal(fetched?.providerExpose, false);
|
||||
});
|
||||
|
||||
test("updateServiceField — rejects non-whitelisted field", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
versionManager.updateServiceField(
|
||||
"9router",
|
||||
"injected_column; DROP TABLE version_manager--",
|
||||
"evil"
|
||||
),
|
||||
/not in the allowed list/
|
||||
);
|
||||
});
|
||||
|
||||
test("updateServiceField — updates lastSyncAt timestamp", async () => {
|
||||
const ts = new Date().toISOString();
|
||||
const updated = await versionManager.updateServiceField("9router", "lastSyncAt", ts);
|
||||
assert.ok(updated !== null);
|
||||
assert.equal(updated.lastSyncAt, ts);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* PM-02 — CI guard against migration version-number collisions.
|
||||
*
|
||||
* The migration runner tracks applied migrations by version (PRIMARY KEY in
|
||||
* _omniroute_migrations). When two files share the same numeric prefix
|
||||
* (e.g. 068_a.sql + 068_b.sql), only the first is applied; the rest are
|
||||
* silently skipped. This caused a production regression in v3.8.4 (three
|
||||
* PRs each shipped a 068_*.sql; see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md).
|
||||
*
|
||||
* SUPERSEDED_DUPLICATE_MIGRATIONS in migrationRunner.ts intentionally allows
|
||||
* known historical pairs (e.g. 041_session_account_affinity superseded by 050).
|
||||
* This test honors that allow-list — only "real" (unmanaged) collisions fail.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const MIGRATIONS_DIR = path.join(process.cwd(), "src/lib/db/migrations");
|
||||
|
||||
// Mirror of SUPERSEDED_DUPLICATE_MIGRATIONS in migrationRunner.ts (line ~156).
|
||||
// Keep in sync if the runner adds new managed pairs.
|
||||
const SUPERSEDED = new Set<string>([
|
||||
"041:session_account_affinity",
|
||||
// Add new "version:name" entries here if you intentionally renumber a migration.
|
||||
]);
|
||||
|
||||
test("no two migration files share the same numeric prefix", () => {
|
||||
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
|
||||
|
||||
const byVersion = new Map<string, string[]>();
|
||||
for (const file of files) {
|
||||
const m = file.match(/^(\d+)_(.+)\.sql$/);
|
||||
if (!m) continue;
|
||||
const [, version, name] = m;
|
||||
if (!byVersion.has(version)) byVersion.set(version, []);
|
||||
byVersion.get(version)!.push(name);
|
||||
}
|
||||
|
||||
const realCollisions = Array.from(byVersion.entries())
|
||||
.filter(([, names]) => names.length > 1)
|
||||
.map(([version, names]) => ({
|
||||
version,
|
||||
liveNames: names.filter((n) => !SUPERSEDED.has(`${version}:${n}`)),
|
||||
}))
|
||||
.filter((c) => c.liveNames.length > 1);
|
||||
|
||||
assert.deepEqual(
|
||||
realCollisions,
|
||||
[],
|
||||
`Migration version collisions detected:\n${realCollisions
|
||||
.map((c) => ` ${c.version}: [${c.liveNames.join(", ")}]`)
|
||||
.join(
|
||||
"\n"
|
||||
)}\n\nFix by renaming one of the files to a unique number AND adding a retroactive guard in migrationRunner.ts isSchemaAlreadyApplied().`
|
||||
);
|
||||
});
|
||||
|
||||
test("every migration file matches the NNN_name.sql convention", () => {
|
||||
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
|
||||
const bad = files.filter((f) => !/^\d+_[^.]+\.sql$/.test(f));
|
||||
assert.deepEqual(bad, [], `Files violating NNN_name.sql convention: ${bad.join(", ")}`);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
test("notion DB module exports expected functions", async () => {
|
||||
const mod = await import("../../../src/lib/db/notion.ts");
|
||||
assert.equal(typeof mod.getNotionToken, "function");
|
||||
assert.equal(typeof mod.setNotionToken, "function");
|
||||
assert.equal(typeof mod.clearNotionToken, "function");
|
||||
assert.equal(typeof mod.getNotionConfig, "function");
|
||||
});
|
||||
|
||||
test("getNotionConfig returns expected shape", async () => {
|
||||
const { getNotionConfig } = await import("../../../src/lib/db/notion.ts");
|
||||
const config = getNotionConfig();
|
||||
assert.ok(typeof config === "object");
|
||||
assert.ok("connected" in config);
|
||||
assert.ok("token" in config);
|
||||
assert.equal(typeof config.connected, "boolean");
|
||||
});
|
||||
|
||||
test("setNotionToken and clearNotionToken are callable without DB", async () => {
|
||||
const { setNotionToken, clearNotionToken } = await import("../../../src/lib/db/notion.ts");
|
||||
assert.doesNotThrow(() => setNotionToken("test"));
|
||||
assert.doesNotThrow(() => clearNotionToken());
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
test("obsidian DB module exports expected functions", async () => {
|
||||
const mod = await import("../../../src/lib/db/obsidian.ts");
|
||||
assert.equal(typeof mod.getObsidianToken, "function");
|
||||
assert.equal(typeof mod.setObsidianToken, "function");
|
||||
assert.equal(typeof mod.clearObsidianToken, "function");
|
||||
assert.equal(typeof mod.getObsidianConfig, "function");
|
||||
});
|
||||
|
||||
test("getObsidianConfig returns expected shape", async () => {
|
||||
const { getObsidianConfig } = await import("../../../src/lib/db/obsidian.ts");
|
||||
const config = getObsidianConfig();
|
||||
assert.ok(typeof config === "object");
|
||||
assert.ok("connected" in config);
|
||||
assert.ok("token" in config);
|
||||
assert.equal(typeof config.connected, "boolean");
|
||||
});
|
||||
|
||||
test("setObsidianToken and clearObsidianToken are callable without DB", async () => {
|
||||
const { setObsidianToken, clearObsidianToken } = await import("../../../src/lib/db/obsidian.ts");
|
||||
assert.doesNotThrow(() => setObsidianToken("test"));
|
||||
assert.doesNotThrow(() => clearObsidianToken());
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* TDD: getPerEngineAnalytics — per-engine aggregation from compression_analytics.
|
||||
*
|
||||
* DB isolation pattern mirrors tests/unit/db/api-keys.test.ts:
|
||||
* - Temp DATA_DIR, resetDbInstance() before/after, close in test.after().
|
||||
*/
|
||||
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";
|
||||
|
||||
// ─── isolated temp DB ─────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-per-engine-analytics-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const { insertCompressionAnalyticsRow, getPerEngineAnalytics } =
|
||||
await import("../../../src/lib/db/compressionAnalytics.ts");
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function resetDb(): void {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getPerEngineAnalytics returns runs=0 and zeroed metrics for unknown engine", () => {
|
||||
const result = getPerEngineAnalytics("unknown-engine");
|
||||
assert.equal(result.engineId, "unknown-engine");
|
||||
assert.equal(result.runs, 0);
|
||||
assert.equal(result.tokensSaved, 0);
|
||||
assert.equal(result.avgSavingsPercent, 0);
|
||||
assert.equal(result.days, 7);
|
||||
});
|
||||
|
||||
test("getPerEngineAnalytics returns correct aggregation for headroom rows only", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 2 headroom rows: original=1000/compressed=800/saved=200 and original=500/compressed=350/saved=150
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 800,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 500,
|
||||
compressed_tokens: 350,
|
||||
tokens_saved: 150,
|
||||
});
|
||||
|
||||
// 1 caveman row that must NOT appear in headroom results
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "caveman",
|
||||
original_tokens: 800,
|
||||
compressed_tokens: 600,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
|
||||
const result = getPerEngineAnalytics("headroom");
|
||||
|
||||
assert.equal(result.engineId, "headroom");
|
||||
assert.equal(result.runs, 2, "should count only the 2 headroom rows");
|
||||
assert.equal(result.tokensSaved, 350, "should sum tokens_saved for headroom rows");
|
||||
|
||||
// avgSavingsPercent = round(((1500 - 1150) / 1500) * 1000) / 10
|
||||
// = round((350/1500) * 1000) / 10
|
||||
// = round(233.33...) / 10
|
||||
// = 233 / 10 = 23.3
|
||||
assert.equal(result.avgSavingsPercent, 23.3, `expected 23.3%, got ${result.avgSavingsPercent}`);
|
||||
assert.equal(result.days, 7);
|
||||
});
|
||||
|
||||
test("getPerEngineAnalytics excludes rows outside the days window", () => {
|
||||
const recent = new Date().toISOString();
|
||||
// 30 days ago — outside the default 7-day window
|
||||
const old = new Date(Date.now() - 30 * 86400_000).toISOString();
|
||||
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: recent,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 800,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: old,
|
||||
mode: "stacked",
|
||||
engine: "headroom",
|
||||
original_tokens: 2000,
|
||||
compressed_tokens: 1000,
|
||||
tokens_saved: 1000,
|
||||
});
|
||||
|
||||
const result = getPerEngineAnalytics("headroom", 7);
|
||||
assert.equal(result.runs, 1, "should only count the recent row within 7 days");
|
||||
assert.equal(result.tokensSaved, 200);
|
||||
});
|
||||
|
||||
test("getPerEngineAnalytics falls back to mode column when engine is null (COALESCE behaviour)", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Insert a row where engine is explicitly omitted — insertCompressionAnalyticsRow
|
||||
// writes engine = row.engine ?? row.mode, so we cannot get NULL via the helper.
|
||||
// Instead, insert raw via the DB to simulate a pre-engine row.
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO compression_analytics (timestamp, mode, engine, original_tokens, compressed_tokens, tokens_saved)
|
||||
VALUES (?, ?, NULL, ?, ?, ?)`
|
||||
).run(now, "caveman", 600, 400, 200);
|
||||
|
||||
// COALESCE(engine, mode) = 'caveman' for the row above
|
||||
const result = getPerEngineAnalytics("caveman");
|
||||
assert.equal(result.runs, 1, "COALESCE fallback should match engine=NULL, mode=caveman");
|
||||
assert.equal(result.tokensSaved, 200);
|
||||
});
|
||||
|
||||
test("getPerEngineAnalytics accepts custom days parameter", () => {
|
||||
const result = getPerEngineAnalytics("headroom", 30);
|
||||
assert.equal(result.days, 30);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* TDD: per-engine breakdown persistence.
|
||||
*
|
||||
* A real stacked run records ONE compression_analytics row (engine = stats.engine ??
|
||||
* mode, e.g. "stacked") — so per-engine savings were previously lost. The
|
||||
* compression_engine_breakdown table stores one row per engine in the stacked
|
||||
* pipeline, and getPerEngineAnalytics aggregates breakdown + legacy single-engine
|
||||
* rows (deduped by request_id, no double counting).
|
||||
*
|
||||
* DB isolation mirrors tests/unit/db/per-engine-analytics.test.ts.
|
||||
*/
|
||||
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-ceb-analytics-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const { insertCompressionAnalyticsRow, insertCompressionEngineBreakdown, getPerEngineAnalytics } =
|
||||
await import("../../../src/lib/db/compressionAnalytics.ts");
|
||||
|
||||
function resetDb(): void {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
test("per-engine breakdown from a stacked run is attributed to each engine", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// One aggregate row for the stacked request (engine column = mode, NOT per-engine).
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "stacked",
|
||||
request_id: "req-stacked-1",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 700,
|
||||
tokens_saved: 300,
|
||||
});
|
||||
|
||||
// Per-engine breakdown for that same request: rtk (1000→800) then headroom (800→700).
|
||||
insertCompressionEngineBreakdown([
|
||||
{
|
||||
timestamp: now,
|
||||
request_id: "req-stacked-1",
|
||||
engine: "rtk",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 800,
|
||||
tokens_saved: 200,
|
||||
},
|
||||
{
|
||||
timestamp: now,
|
||||
request_id: "req-stacked-1",
|
||||
engine: "headroom",
|
||||
original_tokens: 800,
|
||||
compressed_tokens: 700,
|
||||
tokens_saved: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
const headroom = getPerEngineAnalytics("headroom");
|
||||
assert.equal(headroom.runs, 1, "headroom ran once (inside the stacked pipeline)");
|
||||
assert.equal(headroom.tokensSaved, 100, "headroom's own contribution");
|
||||
// avg = round(((800-700)/800)*1000)/10 = round(125)/10 = 12.5
|
||||
assert.equal(headroom.avgSavingsPercent, 12.5);
|
||||
|
||||
const rtk = getPerEngineAnalytics("rtk");
|
||||
assert.equal(rtk.runs, 1);
|
||||
assert.equal(rtk.tokensSaved, 200);
|
||||
});
|
||||
|
||||
test("legacy single-engine rows still count (no breakdown present)", () => {
|
||||
const now = new Date().toISOString();
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "aggressive",
|
||||
engine: "aggressive",
|
||||
request_id: "req-single",
|
||||
original_tokens: 500,
|
||||
compressed_tokens: 400,
|
||||
tokens_saved: 100,
|
||||
});
|
||||
|
||||
const aggressive = getPerEngineAnalytics("aggressive");
|
||||
assert.equal(aggressive.runs, 1, "single-engine run counted via the legacy engine column");
|
||||
assert.equal(aggressive.tokensSaved, 100);
|
||||
});
|
||||
|
||||
test("breakdown + legacy combine for the same engine without double counting", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Stacked run where headroom contributed 100 (recorded in breakdown).
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "stacked",
|
||||
request_id: "req-A",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 800,
|
||||
tokens_saved: 200,
|
||||
});
|
||||
insertCompressionEngineBreakdown([
|
||||
{
|
||||
timestamp: now,
|
||||
request_id: "req-A",
|
||||
engine: "headroom",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 900,
|
||||
tokens_saved: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
// Separate single-engine headroom run (no breakdown) contributed 50.
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "headroom",
|
||||
engine: "headroom",
|
||||
request_id: "req-B",
|
||||
original_tokens: 200,
|
||||
compressed_tokens: 150,
|
||||
tokens_saved: 50,
|
||||
});
|
||||
|
||||
const headroom = getPerEngineAnalytics("headroom");
|
||||
assert.equal(headroom.runs, 2, "one stacked contribution + one single-engine run");
|
||||
assert.equal(headroom.tokensSaved, 150, "100 (stacked) + 50 (single), counted once each");
|
||||
});
|
||||
|
||||
test("a stacked run does NOT double-count the aggregate row under its breakdown engines", () => {
|
||||
const now = new Date().toISOString();
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: now,
|
||||
mode: "stacked",
|
||||
engine: "stacked",
|
||||
request_id: "req-X",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 700,
|
||||
tokens_saved: 300,
|
||||
});
|
||||
insertCompressionEngineBreakdown([
|
||||
{
|
||||
timestamp: now,
|
||||
request_id: "req-X",
|
||||
engine: "caveman",
|
||||
original_tokens: 1000,
|
||||
compressed_tokens: 700,
|
||||
tokens_saved: 300,
|
||||
},
|
||||
]);
|
||||
|
||||
// caveman gets exactly the breakdown contribution, not also the "stacked" aggregate.
|
||||
const caveman = getPerEngineAnalytics("caveman");
|
||||
assert.equal(caveman.runs, 1);
|
||||
assert.equal(caveman.tokensSaved, 300);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for src/lib/db/serviceModels.ts
|
||||
*
|
||||
* Uses an isolated in-memory DB via DATA_DIR override.
|
||||
*/
|
||||
|
||||
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-service-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { getServiceModels, saveServiceModels, markAllUnavailable } =
|
||||
await import("../../../src/lib/db/serviceModels.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getServiceModels — returns [] when no row exists", () => {
|
||||
const result = getServiceModels("9router");
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("saveServiceModels + getServiceModels — round-trips a list (with available flag)", () => {
|
||||
const models = [
|
||||
{ id: "9r/gemma-3n-e4b", name: "Gemma 3n", object: "model", owned_by: "google" },
|
||||
{ id: "9r/llama-3.3-70b", name: "Llama 3.3 70B", object: "model", owned_by: "meta" },
|
||||
];
|
||||
saveServiceModels("9router", models);
|
||||
const result = getServiceModels("9router");
|
||||
assert.equal(result.length, 2);
|
||||
// Models are enriched with available: true on save.
|
||||
assert.equal(result[0].id, "9r/gemma-3n-e4b");
|
||||
assert.equal(result[0].available, true);
|
||||
assert.equal(result[1].id, "9r/llama-3.3-70b");
|
||||
assert.equal(result[1].available, true);
|
||||
});
|
||||
|
||||
test("saveServiceModels — incoming model replaces old; old model pruned to available=false", () => {
|
||||
saveServiceModels("9router", [{ id: "old-model" }]);
|
||||
saveServiceModels("9router", [{ id: "new-model" }]);
|
||||
const result = getServiceModels("9router");
|
||||
// Both old and new are present — old is pruned (available=false), new is active.
|
||||
const byId = Object.fromEntries(result.map((m) => [m.id, m]));
|
||||
assert.ok("old-model" in byId, "old-model should persist (soft delete)");
|
||||
assert.equal(byId["old-model"].available, false);
|
||||
assert.ok("new-model" in byId, "new-model should be present");
|
||||
assert.equal(byId["new-model"].available, true);
|
||||
});
|
||||
|
||||
test("saveServiceModels — saving empty list marks previous models as unavailable (no hard delete)", () => {
|
||||
saveServiceModels("9router", [{ id: "some-model" }]);
|
||||
saveServiceModels("9router", []);
|
||||
const result = getServiceModels("9router");
|
||||
// Pruning keeps the model around as unavailable rather than deleting.
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].id, "some-model");
|
||||
assert.equal(result[0].available, false);
|
||||
});
|
||||
|
||||
test("models are scoped by tool — different tools don't interfere", () => {
|
||||
saveServiceModels("9router", [{ id: "nr-model" }]);
|
||||
saveServiceModels("cliproxyapi", [{ id: "cli-model" }]);
|
||||
|
||||
assert.equal(getServiceModels("9router")[0].id, "nr-model");
|
||||
assert.equal(getServiceModels("cliproxyapi")[0].id, "cli-model");
|
||||
});
|
||||
|
||||
test("getServiceModels — tolerates corrupt JSON by returning []", () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"serviceModels",
|
||||
"9router",
|
||||
"not-valid-json{"
|
||||
);
|
||||
|
||||
const result = getServiceModels("9router");
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("getServiceModels — returns [] when stored value is not an array", () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"serviceModels",
|
||||
"9router",
|
||||
JSON.stringify({ id: "not-an-array" })
|
||||
);
|
||||
|
||||
const result = getServiceModels("9router");
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("saveServiceModels — pruning: models missing from new payload have available=false", () => {
|
||||
// Initial save with 3 models.
|
||||
saveServiceModels("9router", [
|
||||
{ id: "9router/model-a" },
|
||||
{ id: "9router/model-b" },
|
||||
{ id: "9router/model-c" },
|
||||
]);
|
||||
|
||||
// Second save — model-b is missing (pruned), model-c stays, model-d is new.
|
||||
saveServiceModels("9router", [{ id: "9router/model-c" }, { id: "9router/model-d" }]);
|
||||
|
||||
const result = getServiceModels("9router");
|
||||
|
||||
const byId = Object.fromEntries(result.map((m) => [m.id, m]));
|
||||
|
||||
// model-a was in first payload but not second, should be pruned
|
||||
assert.ok("9router/model-a" in byId, "pruned model-a should still exist (soft delete)");
|
||||
assert.equal(byId["9router/model-a"].available, false, "pruned model-a should be unavailable");
|
||||
|
||||
// model-b was in first payload but not second, should be pruned
|
||||
assert.ok("9router/model-b" in byId, "pruned model-b should still exist (soft delete)");
|
||||
assert.equal(byId["9router/model-b"].available, false, "pruned model-b should be unavailable");
|
||||
|
||||
// model-c was in both payloads — should be available
|
||||
assert.equal(byId["9router/model-c"].available, true, "model-c should be available");
|
||||
|
||||
// model-d is new — should be available
|
||||
assert.equal(byId["9router/model-d"].available, true, "model-d should be available");
|
||||
});
|
||||
|
||||
test("saveServiceModels — incoming models are marked available=true", () => {
|
||||
saveServiceModels("9router", [{ id: "9router/cx/gpt-5-mini", name: "GPT-5 mini" }]);
|
||||
const result = getServiceModels("9router");
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].available, true);
|
||||
});
|
||||
|
||||
test("markAllUnavailable — flips all rows for the given tool to available=false", () => {
|
||||
saveServiceModels("9router", [{ id: "9router/model-x" }, { id: "9router/model-y" }]);
|
||||
|
||||
markAllUnavailable("9router");
|
||||
|
||||
const result = getServiceModels("9router");
|
||||
assert.equal(result.length, 2);
|
||||
for (const m of result) {
|
||||
assert.equal(m.available, false, `${m.id} should be unavailable after markAllUnavailable`);
|
||||
}
|
||||
});
|
||||
|
||||
test("markAllUnavailable — does not affect other tools", () => {
|
||||
saveServiceModels("9router", [{ id: "9router/model-x" }]);
|
||||
saveServiceModels("cliproxy", [{ id: "cliproxy/model-z" }]);
|
||||
|
||||
markAllUnavailable("9router");
|
||||
|
||||
const cliproxyModels = getServiceModels("cliproxy");
|
||||
assert.equal(cliproxyModels.length, 1);
|
||||
// cliproxy models should not have been touched
|
||||
assert.notEqual(cliproxyModels[0].available, false, "cliproxy model should not be affected");
|
||||
});
|
||||
|
||||
test("markAllUnavailable — is a no-op when no models stored", () => {
|
||||
assert.doesNotThrow(() => markAllUnavailable("9router"));
|
||||
const result = getServiceModels("9router");
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("available field exists on ServiceModel interface (structural check)", () => {
|
||||
saveServiceModels("9router", [{ id: "9router/test-model", available: true }]);
|
||||
const result = getServiceModels("9router");
|
||||
assert.ok("available" in result[0], "available field should be present in stored model");
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tests for src/lib/db/vacuumScheduler.ts (#4437 / PR #4480)
|
||||
*
|
||||
* Covers:
|
||||
* 1. Module exports the expected public API.
|
||||
* 2. getState() returns the documented shape before any init/run.
|
||||
* 3. init() honors Storage's scheduledVacuum/vacuumHour settings.
|
||||
* 4. refresh() applies Storage setting changes without a restart.
|
||||
* 5. runNow() succeeds on a healthy DB, persists lastRunAt, clears isRunning.
|
||||
* 6. lastRunAt survives a simulated restart (__resetForTests + init reloads the
|
||||
* persisted state from key_value).
|
||||
*
|
||||
* Rebuild note (PR #4480): the original PR test was authored against the Vitest
|
||||
* API and a stale scheduler interface (`state.initialized` / `state.running`),
|
||||
* which never matched the shipped module (`isRunning`, no `initialized`) and was
|
||||
* placed under tests/unit/db/** where the Node native runner — not Vitest —
|
||||
* picks it up. Rewritten for node:test against the real VacuumSchedulerState.
|
||||
*
|
||||
* DB isolation pattern mirrors tests/unit/db/default-combo-toggle.test.ts:
|
||||
* temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
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-vacuum-scheduler-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const scheduler = await import("../../../src/lib/db/vacuumScheduler.ts");
|
||||
|
||||
function setOptimizationSettings(values: { scheduledVacuum?: string; vacuumHour?: number }) {
|
||||
const db = core.getDbInstance();
|
||||
const insert = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
insert.run("databaseSettings", `optimization.${key}`, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
scheduler.__resetForTests();
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("DELETE FROM key_value WHERE namespace IN ('scheduler', 'databaseSettings')").run();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
scheduler.__resetForTests();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
test("module loads and exports the expected public API", () => {
|
||||
assert.equal(typeof scheduler.init, "function");
|
||||
assert.equal(typeof scheduler.stop, "function");
|
||||
assert.equal(typeof scheduler.runNow, "function");
|
||||
assert.equal(typeof scheduler.getState, "function");
|
||||
assert.equal(typeof scheduler.refresh, "function");
|
||||
assert.equal(typeof scheduler.resolveNextRunAt, "function");
|
||||
});
|
||||
|
||||
test("getState() returns the documented shape before any init/run", () => {
|
||||
const state = scheduler.getState();
|
||||
assert.equal(typeof state.enabled, "boolean");
|
||||
assert.equal(typeof state.isRunning, "boolean");
|
||||
assert.equal(state.isRunning, false);
|
||||
assert.equal(state.lastRunAt, null);
|
||||
assert.equal(state.lastDurationMs, null);
|
||||
assert.equal(state.lastError, null);
|
||||
assert.equal(state.nextRunAt, null);
|
||||
});
|
||||
|
||||
test("resolveNextRunAt respects Storage frequency and vacuumHour", () => {
|
||||
const nowBeforeHour = new Date(2026, 0, 1, 1, 30, 0, 0).getTime();
|
||||
const todayAtTwo = new Date(2026, 0, 1, 2, 0, 0, 0).getTime();
|
||||
assert.equal(
|
||||
scheduler.resolveNextRunAt({ scheduledVacuum: "daily", vacuumHour: 2 }, null, nowBeforeHour),
|
||||
todayAtTwo
|
||||
);
|
||||
|
||||
const nowAfterHour = new Date(2026, 0, 1, 3, 0, 0, 0).getTime();
|
||||
const tomorrowAtTwo = new Date(2026, 0, 2, 2, 0, 0, 0).getTime();
|
||||
assert.equal(
|
||||
scheduler.resolveNextRunAt({ scheduledVacuum: "daily", vacuumHour: 2 }, null, nowAfterHour),
|
||||
tomorrowAtTwo
|
||||
);
|
||||
|
||||
const lastRun = new Date(2026, 0, 1, 3, 0, 0, 0).getTime();
|
||||
const nextWeekAtTwo = new Date(2026, 0, 8, 2, 0, 0, 0).getTime();
|
||||
assert.equal(
|
||||
scheduler.resolveNextRunAt({ scheduledVacuum: "weekly", vacuumHour: 2 }, lastRun, lastRun),
|
||||
nextWeekAtTwo
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
scheduler.resolveNextRunAt({ scheduledVacuum: "never", vacuumHour: 2 }, null, nowBeforeHour),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("init() honors Storage scheduledVacuum=never", () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "never", vacuumHour: 4 });
|
||||
const state = scheduler.init();
|
||||
assert.equal(state.enabled, false);
|
||||
assert.equal(state.intervalMs, 0);
|
||||
assert.equal(state.nextRunAt, null);
|
||||
});
|
||||
|
||||
test("init() honors Storage schedule settings", () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "weekly", vacuumHour: 4 });
|
||||
const state = scheduler.init();
|
||||
assert.equal(state.enabled, true);
|
||||
assert.equal(state.intervalMs, 7 * 24 * 60 * 60 * 1000);
|
||||
assert.notEqual(state.nextRunAt, null);
|
||||
});
|
||||
|
||||
test("refresh() applies Storage setting changes without restart", () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "daily", vacuumHour: 1 });
|
||||
assert.equal(scheduler.init().enabled, true);
|
||||
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
const state = scheduler.refresh();
|
||||
assert.equal(state.enabled, false);
|
||||
assert.equal(state.nextRunAt, null);
|
||||
});
|
||||
|
||||
test("init() is idempotent — calling it twice does not throw", () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
assert.doesNotThrow(() => scheduler.init());
|
||||
assert.doesNotThrow(() => scheduler.init());
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
test("stop() is safe to call before init() and is idempotent", () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
scheduler.init();
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
});
|
||||
|
||||
test("runNow() succeeds on a healthy DB and persists lastRunAt", async () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
scheduler.init();
|
||||
try {
|
||||
const result = await scheduler.runNow();
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(typeof result.durationMs, "number");
|
||||
assert.ok(result.durationMs >= 0);
|
||||
|
||||
const state = scheduler.getState();
|
||||
assert.equal(state.isRunning, false);
|
||||
assert.notEqual(state.lastRunAt, null);
|
||||
assert.equal(state.lastError, null);
|
||||
} finally {
|
||||
scheduler.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("runNow() can be called repeatedly; each run succeeds and refreshes lastRunAt", async () => {
|
||||
// better-sqlite3 is synchronous, so VACUUM blocks the event loop for the whole
|
||||
// run — the isRunning guard (which returns "already_running") cannot be
|
||||
// triggered by overlapping awaits in-process. The realistic contract is that
|
||||
// sequential runs each succeed and update lastRunAt.
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
scheduler.init();
|
||||
try {
|
||||
const first = await scheduler.runNow();
|
||||
assert.equal(first.success, true);
|
||||
const second = await scheduler.runNow();
|
||||
assert.equal(second.success, true);
|
||||
assert.equal(scheduler.getState().isRunning, false);
|
||||
assert.notEqual(scheduler.getState().lastRunAt, null);
|
||||
} finally {
|
||||
scheduler.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("lastRunAt survives a simulated restart (state reloaded from key_value)", async () => {
|
||||
setOptimizationSettings({ scheduledVacuum: "never" });
|
||||
scheduler.init();
|
||||
await scheduler.runNow();
|
||||
const beforeRestart = scheduler.getState().lastRunAt;
|
||||
assert.notEqual(beforeRestart, null);
|
||||
|
||||
// Simulate a process restart: wipe in-memory state, then init() reloads the
|
||||
// persisted blob from key_value.
|
||||
scheduler.__resetForTests();
|
||||
assert.equal(scheduler.getState().lastRunAt, null);
|
||||
|
||||
scheduler.init();
|
||||
const afterRestart = scheduler.getState().lastRunAt;
|
||||
assert.equal(afterRestart, beforeRestart);
|
||||
scheduler.stop();
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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-weak-rng-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test("quotaPools.makeId returns UUID without Math.random fallback", async () => {
|
||||
const { createPool } = await import("../../../src/lib/db/quotaPools.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const pool = createPool({ connectionId: "test-conn", name: "test-pool" });
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
assert.match(pool.id, uuidRegex, `ID should be UUID format, got: ${pool.id}`);
|
||||
});
|
||||
|
||||
test("quotaGroups.makeId returns UUID without Math.random fallback", async () => {
|
||||
const { createGroup } = await import("../../../src/lib/db/quotaGroups.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const group = createGroup("test-group");
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
assert.match(group.id, uuidRegex, `ID should be UUID format, got: ${group.id}`);
|
||||
});
|
||||
|
||||
test("quotaPools.makeId produces unique IDs across calls", async () => {
|
||||
const { createPool } = await import("../../../src/lib/db/quotaPools.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
core.resetDbInstance();
|
||||
const pool = createPool({ connectionId: "test-conn", name: `pool-${i}` });
|
||||
ids.add(pool.id);
|
||||
}
|
||||
assert.equal(ids.size, 10, "All 10 IDs should be unique");
|
||||
});
|
||||
|
||||
test("migrationRunner probe table format uses crypto.randomUUID", async () => {
|
||||
const probeName = `__omniroute_fts5_probe_${crypto.randomUUID().replace(/-/g, "_")}`;
|
||||
assert.match(
|
||||
probeName,
|
||||
/^__omniroute_fts5_probe_[0-9a-f]{8}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{4}_[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user