diff --git a/tests/unit/db-provider-plans.test.ts b/tests/unit/db-provider-plans.test.ts new file mode 100644 index 0000000000..ab9c25edae --- /dev/null +++ b/tests/unit/db-provider-plans.test.ts @@ -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); +}); diff --git a/tests/unit/db-quota-consumption.test.ts b/tests/unit/db-quota-consumption.test.ts new file mode 100644 index 0000000000..3a166291f4 --- /dev/null +++ b/tests/unit/db-quota-consumption.test.ts @@ -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); +}); diff --git a/tests/unit/db-quota-migrations-idempotency.test.ts b/tests/unit/db-quota-migrations-idempotency.test.ts new file mode 100644 index 0000000000..969335fc2f --- /dev/null +++ b/tests/unit/db-quota-migrations-idempotency.test.ts @@ -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: (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; + 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"); +}); diff --git a/tests/unit/db-quota-pools.test.ts b/tests/unit/db-quota-pools.test.ts new file mode 100644 index 0000000000..12575f2e24 --- /dev/null +++ b/tests/unit/db-quota-pools.test.ts @@ -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"); +});