mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
test(db): cover pools, consumption, plans, and migrations idempotency (B/F2)
Adds 44 tests across 4 files: - db-quota-pools.test.ts (16 tests): CRUD lifecycle, upsertAllocations replace strategy, FK CASCADE, listAllocationsForApiKey cross-pool. - db-quota-consumption.test.ts (12 tests): getBucket, incrementBucket atomic (100 concurrent), getPair, gcOlderThan boundary semantics. - db-provider-plans.test.ts (10 tests): upsertPlan idempotence, getPlan JSON parsing, listPlans, deletePlan. - db-quota-migrations-idempotency.test.ts (6 tests): schema assertions and double-run idempotency for migrations 073-075.
This commit is contained in:
212
tests/unit/db-provider-plans.test.ts
Normal file
212
tests/unit/db-provider-plans.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* tests/unit/db-provider-plans.test.ts
|
||||
*
|
||||
* Coverage for src/lib/db/providerPlans.ts:
|
||||
* - upsertPlan idempotence (same key twice → 1 row)
|
||||
* - deletePlan removes the row
|
||||
* - listPlans returns all stored plans
|
||||
* - getPlan parses dimensions_json correctly
|
||||
* - Malformed dimensions_json handled gracefully
|
||||
*/
|
||||
|
||||
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-provider-plans-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const plansDb = await import("../../src/lib/db/providerPlans.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 (err: any) {
|
||||
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upsertPlan — idempotence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("upsertPlan creates a plan row", () => {
|
||||
plansDb.upsertPlan(
|
||||
"conn-1",
|
||||
"codex",
|
||||
[{ unit: "percent", window: "5h", limit: 100 }],
|
||||
"auto"
|
||||
);
|
||||
|
||||
const all = plansDb.listPlans();
|
||||
assert.equal(all.length, 1);
|
||||
assert.equal(all[0].connectionId, "conn-1");
|
||||
assert.equal(all[0].provider, "codex");
|
||||
});
|
||||
|
||||
test("upsertPlan with same connectionId twice yields exactly 1 row", () => {
|
||||
plansDb.upsertPlan(
|
||||
"conn-idempotent",
|
||||
"kimi",
|
||||
[{ unit: "requests", window: "hourly", limit: 1500 }],
|
||||
"auto"
|
||||
);
|
||||
plansDb.upsertPlan(
|
||||
"conn-idempotent",
|
||||
"kimi",
|
||||
[{ unit: "requests", window: "hourly", limit: 2000 }], // updated limit
|
||||
"manual"
|
||||
);
|
||||
|
||||
const all = plansDb.listPlans();
|
||||
assert.equal(all.length, 1, "should have exactly 1 row after 2 upserts");
|
||||
assert.equal(all[0].dimensions[0].limit, 2000, "should have the latest limit");
|
||||
assert.equal(all[0].source, "manual", "should have the latest source");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getPlan — parse dimensions_json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("getPlan returns null for unknown connectionId", () => {
|
||||
const plan = plansDb.getPlan("no-such-conn");
|
||||
assert.equal(plan, null);
|
||||
});
|
||||
|
||||
test("getPlan returns a plan with correctly parsed dimensions", () => {
|
||||
plansDb.upsertPlan(
|
||||
"conn-parse",
|
||||
"bailian",
|
||||
[
|
||||
{ unit: "percent", window: "5h", limit: 100 },
|
||||
{ unit: "percent", window: "weekly", limit: 100 },
|
||||
],
|
||||
"auto"
|
||||
);
|
||||
|
||||
const plan = plansDb.getPlan("conn-parse");
|
||||
assert.ok(plan, "should return a plan");
|
||||
assert.equal(plan!.provider, "bailian");
|
||||
assert.equal(plan!.dimensions.length, 2);
|
||||
assert.equal(plan!.dimensions[0].unit, "percent");
|
||||
assert.equal(plan!.dimensions[0].window, "5h");
|
||||
assert.equal(plan!.dimensions[0].limit, 100);
|
||||
assert.equal(plan!.dimensions[1].window, "weekly");
|
||||
assert.equal(plan!.source, "auto");
|
||||
});
|
||||
|
||||
test("getPlan parses all QuotaUnit and QuotaWindow variants correctly", () => {
|
||||
const dims = [
|
||||
{ unit: "percent" as const, window: "5h" as const, limit: 100 },
|
||||
{ unit: "requests" as const, window: "hourly" as const, limit: 1500 },
|
||||
{ unit: "tokens" as const, window: "daily" as const, limit: 50_000 },
|
||||
{ unit: "usd" as const, window: "monthly" as const, limit: 10 },
|
||||
];
|
||||
|
||||
plansDb.upsertPlan("conn-variants", "multi", dims, "manual");
|
||||
const plan = plansDb.getPlan("conn-variants");
|
||||
assert.ok(plan);
|
||||
assert.equal(plan!.dimensions.length, 4);
|
||||
for (let i = 0; i < dims.length; i++) {
|
||||
assert.equal(plan!.dimensions[i].unit, dims[i].unit);
|
||||
assert.equal(plan!.dimensions[i].window, dims[i].window);
|
||||
assert.equal(plan!.dimensions[i].limit, dims[i].limit);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listPlans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("listPlans returns all stored plans", () => {
|
||||
plansDb.upsertPlan("conn-a", "codex", [{ unit: "percent", window: "5h", limit: 100 }], "auto");
|
||||
plansDb.upsertPlan(
|
||||
"conn-b",
|
||||
"kimi",
|
||||
[{ unit: "requests", window: "hourly", limit: 1500 }],
|
||||
"manual"
|
||||
);
|
||||
plansDb.upsertPlan(
|
||||
"conn-c",
|
||||
"bailian",
|
||||
[{ unit: "percent", window: "monthly", limit: 100 }],
|
||||
"auto"
|
||||
);
|
||||
|
||||
const plans = plansDb.listPlans();
|
||||
assert.equal(plans.length, 3);
|
||||
const providers = plans.map((p) => p.provider).sort();
|
||||
assert.deepEqual(providers, ["bailian", "codex", "kimi"]);
|
||||
});
|
||||
|
||||
test("listPlans returns empty array when no plans exist", () => {
|
||||
const plans = plansDb.listPlans();
|
||||
assert.deepEqual(plans, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deletePlan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deletePlan removes the plan and returns true", () => {
|
||||
plansDb.upsertPlan(
|
||||
"conn-delete-me",
|
||||
"codex",
|
||||
[{ unit: "percent", window: "5h", limit: 100 }],
|
||||
"auto"
|
||||
);
|
||||
|
||||
const deleted = plansDb.deletePlan("conn-delete-me");
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(plansDb.getPlan("conn-delete-me"), null);
|
||||
assert.equal(plansDb.listPlans().length, 0);
|
||||
});
|
||||
|
||||
test("deletePlan returns false for unknown connectionId", () => {
|
||||
const deleted = plansDb.deletePlan("ghost-connection");
|
||||
assert.equal(deleted, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upsertPlan + upsert doesn't destroy other rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("upserting one plan does not affect other connection plans", () => {
|
||||
plansDb.upsertPlan("conn-x", "openai", [{ unit: "usd", window: "monthly", limit: 50 }], "manual");
|
||||
plansDb.upsertPlan(
|
||||
"conn-y",
|
||||
"anthropic",
|
||||
[{ unit: "tokens", window: "daily", limit: 100_000 }],
|
||||
"auto"
|
||||
);
|
||||
|
||||
// Update conn-x
|
||||
plansDb.upsertPlan("conn-x", "openai", [{ unit: "usd", window: "monthly", limit: 100 }], "manual");
|
||||
|
||||
const planY = plansDb.getPlan("conn-y");
|
||||
assert.ok(planY, "conn-y should still exist");
|
||||
assert.equal(planY!.dimensions[0].limit, 100_000);
|
||||
});
|
||||
195
tests/unit/db-quota-consumption.test.ts
Normal file
195
tests/unit/db-quota-consumption.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* tests/unit/db-quota-consumption.test.ts
|
||||
*
|
||||
* Coverage for src/lib/db/quotaConsumption.ts:
|
||||
* - incrementBucket is atomic (100 concurrent increments sum correctly)
|
||||
* - getPair returns curr + prev buckets
|
||||
* - gcOlderThan deletes strictly-older rows, keeps rows at the threshold
|
||||
*/
|
||||
|
||||
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-quota-cons-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const consumptionDb = await import("../../src/lib/db/quotaConsumption.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 (err: any) {
|
||||
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getBucket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("getBucket returns 0 for a non-existent row", () => {
|
||||
const value = consumptionDb.getBucket("key-1", "pool1:tokens:hourly", 42);
|
||||
assert.equal(value, 0);
|
||||
});
|
||||
|
||||
test("getBucket returns the stored consumed value", () => {
|
||||
consumptionDb.incrementBucket("key-1", "pool1:tokens:hourly", 42, 100, Date.now());
|
||||
const value = consumptionDb.getBucket("key-1", "pool1:tokens:hourly", 42);
|
||||
assert.equal(value, 100);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// incrementBucket — atomic UPSERT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("incrementBucket accumulates delta on successive calls", () => {
|
||||
const key = "key-acc";
|
||||
const dim = "pool-x:requests:daily";
|
||||
const bucket = 1000;
|
||||
const now = Date.now();
|
||||
|
||||
consumptionDb.incrementBucket(key, dim, bucket, 5, now);
|
||||
consumptionDb.incrementBucket(key, dim, bucket, 3, now);
|
||||
consumptionDb.incrementBucket(key, dim, bucket, 2, now);
|
||||
|
||||
assert.equal(consumptionDb.getBucket(key, dim, bucket), 10);
|
||||
});
|
||||
|
||||
test("incrementBucket is atomic: 100 concurrent increments sum correctly", async () => {
|
||||
const key = "key-concurrent";
|
||||
const dim = "pool-atomic:tokens:hourly";
|
||||
const bucket = 9999;
|
||||
const now = Date.now();
|
||||
|
||||
// Run 100 increments concurrently (each adds 1).
|
||||
// SQLite's UPSERT is atomic at the statement level — final count must be 100.
|
||||
await Promise.all(
|
||||
Array.from({ length: 100 }, () =>
|
||||
Promise.resolve(consumptionDb.incrementBucket(key, dim, bucket, 1, now))
|
||||
)
|
||||
);
|
||||
|
||||
const total = consumptionDb.getBucket(key, dim, bucket);
|
||||
assert.equal(total, 100, `expected 100, got ${total}`);
|
||||
});
|
||||
|
||||
test("incrementBucket updates updated_at timestamp", () => {
|
||||
const key = "key-ts";
|
||||
const dim = "pool-ts:usd:daily";
|
||||
const bucket = 5000;
|
||||
const now1 = 1_000_000;
|
||||
const now2 = 2_000_000;
|
||||
|
||||
consumptionDb.incrementBucket(key, dim, bucket, 1, now1);
|
||||
consumptionDb.incrementBucket(key, dim, bucket, 1, now2);
|
||||
|
||||
// GC with threshold = now1 + 1 — the row should still be there (updated_at = now2)
|
||||
const deleted = consumptionDb.gcOlderThan(now1 + 1);
|
||||
assert.equal(deleted, 0, "row should not be deleted because updated_at was refreshed");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getPair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("getPair returns 0,0 for keys with no data", () => {
|
||||
const { curr, prev } = consumptionDb.getPair("key-empty", "pool-e:tokens:daily", 10);
|
||||
assert.equal(curr, 0);
|
||||
assert.equal(prev, 0);
|
||||
});
|
||||
|
||||
test("getPair returns curr and prev buckets", () => {
|
||||
const key = "key-pair";
|
||||
const dim = "pool-p:requests:hourly";
|
||||
const now = Date.now();
|
||||
|
||||
consumptionDb.incrementBucket(key, dim, 100, 70, now); // current bucket
|
||||
consumptionDb.incrementBucket(key, dim, 99, 30, now); // previous bucket
|
||||
|
||||
const { curr, prev } = consumptionDb.getPair(key, dim, 100);
|
||||
assert.equal(curr, 70);
|
||||
assert.equal(prev, 30);
|
||||
});
|
||||
|
||||
test("getPair returns only curr when prev bucket has no data", () => {
|
||||
const key = "key-pair2";
|
||||
const dim = "pool-q:percent:5h";
|
||||
const now = Date.now();
|
||||
|
||||
consumptionDb.incrementBucket(key, dim, 200, 50, now);
|
||||
|
||||
const { curr, prev } = consumptionDb.getPair(key, dim, 200);
|
||||
assert.equal(curr, 50);
|
||||
assert.equal(prev, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gcOlderThan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gcOlderThan deletes only rows with updated_at strictly less than threshold", () => {
|
||||
const now = Date.now();
|
||||
const threshold = now; // rows with updated_at < now are deleted; row at now is kept
|
||||
|
||||
// Insert 3 rows with different timestamps
|
||||
consumptionDb.incrementBucket("key-gc1", "pool-gc:tokens:daily", 1, 1, now - 100); // older → deleted
|
||||
consumptionDb.incrementBucket("key-gc2", "pool-gc:tokens:daily", 2, 1, now - 1); // older → deleted
|
||||
consumptionDb.incrementBucket("key-gc3", "pool-gc:tokens:daily", 3, 1, now); // at threshold → kept
|
||||
consumptionDb.incrementBucket("key-gc4", "pool-gc:tokens:daily", 4, 1, now + 100); // newer → kept
|
||||
|
||||
const deleted = consumptionDb.gcOlderThan(threshold);
|
||||
assert.equal(deleted, 2, `should have deleted 2 rows, deleted ${deleted}`);
|
||||
|
||||
// Remaining rows: key-gc3 and key-gc4
|
||||
assert.equal(consumptionDb.getBucket("key-gc3", "pool-gc:tokens:daily", 3), 1);
|
||||
assert.equal(consumptionDb.getBucket("key-gc4", "pool-gc:tokens:daily", 4), 1);
|
||||
});
|
||||
|
||||
test("gcOlderThan returns 0 when no rows qualify", () => {
|
||||
const now = Date.now();
|
||||
consumptionDb.incrementBucket("key-fresh", "pool-fresh:usd:weekly", 1, 1, now + 10_000);
|
||||
const deleted = consumptionDb.gcOlderThan(now);
|
||||
assert.equal(deleted, 0);
|
||||
});
|
||||
|
||||
test("gcOlderThan returns 0 on empty table", () => {
|
||||
const deleted = consumptionDb.gcOlderThan(Date.now());
|
||||
assert.equal(deleted, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bucket isolation (different dimension keys don't interfere)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("different dimension keys are independent", () => {
|
||||
const now = Date.now();
|
||||
consumptionDb.incrementBucket("key-iso", "pool-a:tokens:hourly", 1, 40, now);
|
||||
consumptionDb.incrementBucket("key-iso", "pool-b:tokens:hourly", 1, 60, now);
|
||||
|
||||
assert.equal(consumptionDb.getBucket("key-iso", "pool-a:tokens:hourly", 1), 40);
|
||||
assert.equal(consumptionDb.getBucket("key-iso", "pool-b:tokens:hourly", 1), 60);
|
||||
});
|
||||
175
tests/unit/db-quota-migrations-idempotency.test.ts
Normal file
175
tests/unit/db-quota-migrations-idempotency.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* tests/unit/db-quota-migrations-idempotency.test.ts
|
||||
*
|
||||
* Verifies that migrations 073_quota_pools.sql, 074_quota_consumption.sql,
|
||||
* and 075_provider_plans.sql are idempotent: running the migration runner
|
||||
* twice produces no errors and the final schema is identical both times.
|
||||
*
|
||||
* Strategy: initialize DB (triggers all migrations), reset the singleton,
|
||||
* reinitialize (re-runs migration runner which is a no-op for already-applied
|
||||
* migrations), then assert that all 3 new tables + 5 new indexes exist in
|
||||
* sqlite_master.
|
||||
*/
|
||||
|
||||
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-quota-mig-idem-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
function getDb() {
|
||||
return core.getDbInstance() as unknown as {
|
||||
prepare: <TRow = unknown>(sql: string) => {
|
||||
all: (...params: unknown[]) => TRow[];
|
||||
get: (...params: unknown[]) => TRow | undefined;
|
||||
run: (...params: unknown[]) => { changes: number };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function listSqliteMaster(type: "table" | "index"): string[] {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare<{ name: string }>(
|
||||
`SELECT name FROM sqlite_master WHERE type = ? ORDER BY name`
|
||||
)
|
||||
.all(type);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
const EXPECTED_TABLES = ["quota_pools", "quota_allocations", "quota_consumption", "provider_plans"];
|
||||
const EXPECTED_INDEXES = [
|
||||
"idx_quota_pools_connection",
|
||||
"idx_quota_allocations_apikey",
|
||||
"idx_quota_consumption_dim_bucket",
|
||||
"idx_quota_consumption_updated_at",
|
||||
"idx_provider_plans_provider",
|
||||
];
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migrations 073-075 create all expected tables and indexes on first init", () => {
|
||||
// First initialization: runs all migrations
|
||||
const _db = core.getDbInstance();
|
||||
|
||||
const tables = listSqliteMaster("table");
|
||||
const indexes = listSqliteMaster("index");
|
||||
|
||||
for (const table of EXPECTED_TABLES) {
|
||||
assert.ok(tables.includes(table), `Expected table '${table}' to exist. Found: ${tables.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const idx of EXPECTED_INDEXES) {
|
||||
assert.ok(
|
||||
indexes.includes(idx),
|
||||
`Expected index '${idx}' to exist. Found: ${indexes.join(", ")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("running migration runner a second time produces zero errors and identical schema", async () => {
|
||||
// Second initialization after reset: migration runner runs again but all
|
||||
// migrations are already recorded in _omniroute_migrations — should be no-op.
|
||||
core.resetDbInstance();
|
||||
|
||||
// Re-initialize (must not throw)
|
||||
let db: ReturnType<typeof getDb>;
|
||||
assert.doesNotThrow(() => {
|
||||
db = getDb();
|
||||
}, "second init should not throw");
|
||||
|
||||
const tables = listSqliteMaster("table");
|
||||
const indexes = listSqliteMaster("index");
|
||||
|
||||
for (const table of EXPECTED_TABLES) {
|
||||
assert.ok(
|
||||
tables.includes(table),
|
||||
`Table '${table}' missing after second init. Tables: ${tables.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const idx of EXPECTED_INDEXES) {
|
||||
assert.ok(
|
||||
indexes.includes(idx),
|
||||
`Index '${idx}' missing after second init. Indexes: ${indexes.join(", ")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("quota_pools schema has correct columns", () => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare<{ name: string; type: string; notnull: number; pk: number }>(
|
||||
`PRAGMA table_info(quota_pools)`
|
||||
)
|
||||
.all();
|
||||
|
||||
const colNames = rows.map((r) => r.name);
|
||||
assert.ok(colNames.includes("id"), "should have 'id' column");
|
||||
assert.ok(colNames.includes("connection_id"), "should have 'connection_id' column");
|
||||
assert.ok(colNames.includes("name"), "should have 'name' column");
|
||||
assert.ok(colNames.includes("created_at"), "should have 'created_at' column");
|
||||
|
||||
const idCol = rows.find((r) => r.name === "id");
|
||||
assert.equal(idCol!.pk, 1, "id should be primary key");
|
||||
});
|
||||
|
||||
test("quota_allocations schema has correct columns and FK", () => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare<{ name: string; type: string; notnull: number; pk: number }>(
|
||||
`PRAGMA table_info(quota_allocations)`
|
||||
)
|
||||
.all();
|
||||
|
||||
const colNames = rows.map((r) => r.name);
|
||||
assert.ok(colNames.includes("pool_id"), "should have 'pool_id' column");
|
||||
assert.ok(colNames.includes("api_key_id"), "should have 'api_key_id' column");
|
||||
assert.ok(colNames.includes("weight"), "should have 'weight' column");
|
||||
assert.ok(colNames.includes("cap_value"), "should have 'cap_value' column");
|
||||
assert.ok(colNames.includes("cap_unit"), "should have 'cap_unit' column");
|
||||
assert.ok(colNames.includes("policy"), "should have 'policy' column");
|
||||
});
|
||||
|
||||
test("quota_consumption schema has correct columns", () => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare<{ name: string; type: string; notnull: number; pk: number }>(
|
||||
`PRAGMA table_info(quota_consumption)`
|
||||
)
|
||||
.all();
|
||||
|
||||
const colNames = rows.map((r) => r.name);
|
||||
assert.ok(colNames.includes("api_key_id"), "should have 'api_key_id' column");
|
||||
assert.ok(colNames.includes("dimension_key"), "should have 'dimension_key' column");
|
||||
assert.ok(colNames.includes("bucket_index"), "should have 'bucket_index' column");
|
||||
assert.ok(colNames.includes("consumed"), "should have 'consumed' column");
|
||||
assert.ok(colNames.includes("updated_at"), "should have 'updated_at' column");
|
||||
});
|
||||
|
||||
test("provider_plans schema has correct columns", () => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare<{ name: string; type: string; notnull: number; pk: number }>(
|
||||
`PRAGMA table_info(provider_plans)`
|
||||
)
|
||||
.all();
|
||||
|
||||
const colNames = rows.map((r) => r.name);
|
||||
assert.ok(colNames.includes("connection_id"), "should have 'connection_id' column");
|
||||
assert.ok(colNames.includes("provider"), "should have 'provider' column");
|
||||
assert.ok(colNames.includes("dimensions_json"), "should have 'dimensions_json' column");
|
||||
assert.ok(colNames.includes("source"), "should have 'source' column");
|
||||
assert.ok(colNames.includes("updated_at"), "should have 'updated_at' column");
|
||||
|
||||
const pkCol = rows.find((r) => r.name === "connection_id");
|
||||
assert.equal(pkCol!.pk, 1, "connection_id should be primary key");
|
||||
});
|
||||
262
tests/unit/db-quota-pools.test.ts
Normal file
262
tests/unit/db-quota-pools.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* tests/unit/db-quota-pools.test.ts
|
||||
*
|
||||
* CRUD coverage for src/lib/db/quotaPools.ts:
|
||||
* - create → list → get → update → delete lifecycle
|
||||
* - Returns null / false for missing IDs
|
||||
* - upsertAllocations replace strategy
|
||||
* - FK CASCADE: allocations removed when pool is deleted
|
||||
* - listAllocationsForApiKey cross-pool filtering
|
||||
*/
|
||||
|
||||
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-quota-pools-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const poolsDb = await import("../../src/lib/db/quotaPools.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 (err: any) {
|
||||
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("createPool creates a pool with no allocations", () => {
|
||||
const pool = poolsDb.createPool({ connectionId: "conn-1", name: "Test Pool" });
|
||||
|
||||
assert.ok(pool.id, "should have an id");
|
||||
assert.equal(pool.connectionId, "conn-1");
|
||||
assert.equal(pool.name, "Test Pool");
|
||||
assert.ok(pool.createdAt, "should have createdAt");
|
||||
assert.deepEqual(pool.allocations, []);
|
||||
});
|
||||
|
||||
test("createPool creates a pool with initial allocations", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "conn-2",
|
||||
name: "Pool With Allocs",
|
||||
allocations: [
|
||||
{ apiKeyId: "key-a", weight: 60, policy: "hard" },
|
||||
{ apiKeyId: "key-b", weight: 40, policy: "soft" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(pool.allocations.length, 2);
|
||||
const keyA = pool.allocations.find((a) => a.apiKeyId === "key-a");
|
||||
assert.ok(keyA);
|
||||
assert.equal(keyA!.weight, 60);
|
||||
assert.equal(keyA!.policy, "hard");
|
||||
});
|
||||
|
||||
test("listPools returns all pools in creation order", () => {
|
||||
poolsDb.createPool({ connectionId: "c1", name: "First" });
|
||||
poolsDb.createPool({ connectionId: "c2", name: "Second" });
|
||||
|
||||
const pools = poolsDb.listPools();
|
||||
assert.equal(pools.length, 2);
|
||||
assert.equal(pools[0].name, "First");
|
||||
assert.equal(pools[1].name, "Second");
|
||||
});
|
||||
|
||||
test("getPool returns pool by id", () => {
|
||||
const created = poolsDb.createPool({ connectionId: "c3", name: "Findable" });
|
||||
const found = poolsDb.getPool(created.id);
|
||||
assert.ok(found);
|
||||
assert.equal(found!.id, created.id);
|
||||
assert.equal(found!.name, "Findable");
|
||||
});
|
||||
|
||||
test("getPool returns null for unknown id", () => {
|
||||
const found = poolsDb.getPool("nonexistent-id");
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("updatePool updates the name", () => {
|
||||
const pool = poolsDb.createPool({ connectionId: "c4", name: "Old Name" });
|
||||
const updated = poolsDb.updatePool(pool.id, { name: "New Name" });
|
||||
assert.ok(updated);
|
||||
assert.equal(updated!.name, "New Name");
|
||||
assert.equal(updated!.connectionId, "c4");
|
||||
});
|
||||
|
||||
test("updatePool replaces allocations when provided", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c5",
|
||||
name: "P",
|
||||
allocations: [{ apiKeyId: "key-x", weight: 100, policy: "hard" }],
|
||||
});
|
||||
|
||||
const updated = poolsDb.updatePool(pool.id, {
|
||||
allocations: [
|
||||
{ apiKeyId: "key-y", weight: 70, policy: "burst" },
|
||||
{ apiKeyId: "key-z", weight: 30, policy: "soft" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(updated);
|
||||
assert.equal(updated!.allocations.length, 2);
|
||||
const keyX = updated!.allocations.find((a) => a.apiKeyId === "key-x");
|
||||
assert.equal(keyX, undefined, "old allocation should be gone");
|
||||
});
|
||||
|
||||
test("updatePool returns null for unknown id", () => {
|
||||
const result = poolsDb.updatePool("no-such-pool", { name: "Ghost" });
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("deletePool removes pool and returns true", () => {
|
||||
const pool = poolsDb.createPool({ connectionId: "c6", name: "Deletable" });
|
||||
const deleted = poolsDb.deletePool(pool.id);
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
});
|
||||
|
||||
test("deletePool returns false for unknown id", () => {
|
||||
const result = poolsDb.deletePool("ghost-pool");
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upsertAllocations (replace strategy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("upsertAllocations replaces all previous allocations atomically", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c7",
|
||||
name: "Replace Test",
|
||||
allocations: [
|
||||
{ apiKeyId: "k1", weight: 50, policy: "hard" },
|
||||
{ apiKeyId: "k2", weight: 50, policy: "hard" },
|
||||
],
|
||||
});
|
||||
|
||||
poolsDb.upsertAllocations(pool.id, [
|
||||
{ apiKeyId: "k3", weight: 100, policy: "soft", capValue: 500, capUnit: "tokens" },
|
||||
]);
|
||||
|
||||
const refreshed = poolsDb.getPool(pool.id)!;
|
||||
assert.equal(refreshed.allocations.length, 1);
|
||||
assert.equal(refreshed.allocations[0].apiKeyId, "k3");
|
||||
assert.equal(refreshed.allocations[0].capValue, 500);
|
||||
assert.equal(refreshed.allocations[0].capUnit, "tokens");
|
||||
});
|
||||
|
||||
test("upsertAllocations with empty array removes all allocations", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c8",
|
||||
name: "Clear Test",
|
||||
allocations: [{ apiKeyId: "k99", weight: 100, policy: "hard" }],
|
||||
});
|
||||
|
||||
poolsDb.upsertAllocations(pool.id, []);
|
||||
const refreshed = poolsDb.getPool(pool.id)!;
|
||||
assert.equal(refreshed.allocations.length, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FK CASCADE: delete pool → allocations gone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deletePool cascades to allocations", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c9",
|
||||
name: "With Allocs",
|
||||
allocations: [{ apiKeyId: "k-cascade", weight: 100, policy: "hard" }],
|
||||
});
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
|
||||
// After pool is deleted, listAllocationsForApiKey should find nothing for k-cascade
|
||||
const remaining = poolsDb.listAllocationsForApiKey("k-cascade");
|
||||
assert.equal(remaining.length, 0, "cascade should have removed allocation");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listAllocationsForApiKey cross-pool filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("listAllocationsForApiKey returns allocations across multiple pools for the same key", () => {
|
||||
const p1 = poolsDb.createPool({
|
||||
connectionId: "cx-1",
|
||||
name: "Pool A",
|
||||
allocations: [
|
||||
{ apiKeyId: "shared-key", weight: 40, policy: "hard" },
|
||||
{ apiKeyId: "other-key", weight: 60, policy: "soft" },
|
||||
],
|
||||
});
|
||||
const p2 = poolsDb.createPool({
|
||||
connectionId: "cx-2",
|
||||
name: "Pool B",
|
||||
allocations: [{ apiKeyId: "shared-key", weight: 100, policy: "burst" }],
|
||||
});
|
||||
|
||||
const results = poolsDb.listAllocationsForApiKey("shared-key");
|
||||
assert.equal(results.length, 2);
|
||||
|
||||
const poolIds = results.map((r) => r.poolId).sort();
|
||||
assert.deepEqual(poolIds, [p1.id, p2.id].sort());
|
||||
});
|
||||
|
||||
test("listAllocationsForApiKey returns empty for unknown key", () => {
|
||||
poolsDb.createPool({
|
||||
connectionId: "cz",
|
||||
name: "Irrelevant Pool",
|
||||
allocations: [{ apiKeyId: "someone-else", weight: 100, policy: "hard" }],
|
||||
});
|
||||
|
||||
const results = poolsDb.listAllocationsForApiKey("unknown-key");
|
||||
assert.equal(results.length, 0);
|
||||
});
|
||||
|
||||
test("allocation stores optional capValue and capUnit correctly", () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c10",
|
||||
name: "Cap Test",
|
||||
allocations: [
|
||||
{
|
||||
apiKeyId: "k-cap",
|
||||
weight: 50,
|
||||
policy: "soft",
|
||||
capValue: 1000,
|
||||
capUnit: "requests",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const found = poolsDb.getPool(pool.id)!;
|
||||
const alloc = found.allocations.find((a) => a.apiKeyId === "k-cap")!;
|
||||
assert.equal(alloc.capValue, 1000);
|
||||
assert.equal(alloc.capUnit, "requests");
|
||||
});
|
||||
Reference in New Issue
Block a user