diff --git a/tests/unit/db-apiKeys-crud.test.ts b/tests/unit/db-apiKeys-crud.test.ts new file mode 100644 index 0000000000..641e9515c1 --- /dev/null +++ b/tests/unit/db-apiKeys-crud.test.ts @@ -0,0 +1,542 @@ +/** + * Tests for src/lib/db/apiKeys.ts — API key lifecycle, validation, caching, wildcard matching. + * + * Coverage targets: + * - createApiKey, getApiKeys, getApiKeyById + * - validateApiKey (env key, DB-backed, cache, banned/revoked/expired/inactive) + * - getApiKeyMetadata (env key, DB-backed, cache) + * - isModelAllowedForKey (no restrictions, exact, prefix, wildcard, group deny) + * - updateApiKeyPermissions (all field types, scopes with transaction) + * - deleteApiKey, revokeApiKey, setApiKeyExpiry, regenerateApiKey + * - clearApiKeyCaches, resetApiKeyState + * - matchesWildcardPattern (unit-level) + */ + +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-apikeys-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-for-crc-operations-do-not-use-in-prod"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeys = await import("../../src/lib/db/apiKeys.ts"); + +async function resetStorage() { + apiKeys.resetApiKeyState(); + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch { + await new Promise((r) => setTimeout(r, 50 * (attempt + 1))); + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); +} + +await resetStorage(); + +// ──────────────── createApiKey ──────────────── + +test("createApiKey creates a key and returns it with id, key, name, machineId", async () => { + await resetStorage(); + const key = await apiKeys.createApiKey("Test Key", "machine-001"); + assert.ok(key.id); + assert.ok(key.key); + assert.ok(key.key.startsWith("omni_") || key.key.length > 0); + assert.equal(key.name, "Test Key"); + assert.equal(key.machineId, "machine-001"); +}); + +test("createApiKey with scopes stores them", async () => { + await resetStorage(); + const key = await apiKeys.createApiKey("Scoped Key", "machine-002", ["read", "write"]); + assert.ok(key.id); + assert.deepEqual(key.scopes, ["read", "write"]); +}); + +test("createApiKey rejects empty machineId", async () => { + await resetStorage(); + await assert.rejects( + () => apiKeys.createApiKey("Bad Key", ""), + { message: /machineId is required/i } + ); +}); + +// ──────────────── getApiKeys ──────────────── + +test("getApiKeys returns empty array when no keys exist", async () => { + await resetStorage(); + const keys = await apiKeys.getApiKeys(); + assert.deepEqual(keys, []); +}); + +test("getApiKeys returns all created keys", async () => { + await resetStorage(); + await apiKeys.createApiKey("Key A", "ma-001"); + await apiKeys.createApiKey("Key B", "ma-002"); + const all = await apiKeys.getApiKeys(); + assert.equal(all.length, 2); + const names = all.map((k) => k.name).sort(); + assert.deepEqual(names, ["Key A", "Key B"]); +}); + +// ──────────────── getApiKeyById ──────────────── + +test("getApiKeyById returns key by id", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Find Me", "ma-003"); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.ok(loaded !== null); + assert.equal(loaded!.name, "Find Me"); + assert.equal(loaded!.id, created.id); + assert.equal(loaded!.machineId, "ma-003"); +}); + +test("getApiKeyById returns null for missing id", async () => { + await resetStorage(); + const loaded = await apiKeys.getApiKeyById("no-such-id"); + assert.equal(loaded, null); +}); + +// ──────────────── validateApiKey ──────────────── + +test("validateApiKey returns false for null / undefined / empty", async () => { + await resetStorage(); + assert.equal(await apiKeys.validateApiKey(null), false); + assert.equal(await apiKeys.validateApiKey(undefined), false); + assert.equal(await apiKeys.validateApiKey(""), false); +}); + +test("validateApiKey returns true for env key", async () => { + await resetStorage(); + const prev = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = "env-key-test-abc123"; + try { + assert.equal(await apiKeys.validateApiKey("env-key-test-abc123"), true); + } finally { + process.env.OMNIROUTE_API_KEY = prev; + } +}); + +test("validateApiKey returns true for valid key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Valid Key", "ma-004"); + assert.equal(await apiKeys.validateApiKey(created.key), true); +}); + +test("validateApiKey returns false for non-existent key", async () => { + await resetStorage(); + assert.equal(await apiKeys.validateApiKey("omni_nonexistent_key_abc123"), false); +}); + +test("validateApiKey returns false for banned key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Banned Soon", "ma-005"); + await apiKeys.updateApiKeyPermissions(created.id, { isBanned: true }); + + // Caches may still hold the old valid state — validateApiKey should check DB + // after cache miss; call resetApiKeyState() to clear caches for a fresh read. + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.validateApiKey(created.key), false); +}); + +test("validateApiKey returns false for revoked key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Revoked Soon", "ma-006"); + await apiKeys.revokeApiKey(created.id); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.validateApiKey(created.key), false); +}); + +test("validateApiKey returns false for expired key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Expiring Soon", "ma-007"); + await apiKeys.setApiKeyExpiry(created.id, "2020-01-01T00:00:00Z"); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.validateApiKey(created.key), false); +}); + +test("validateApiKey returns false for inactive key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Inactive Soon", "ma-008"); + await apiKeys.updateApiKeyPermissions(created.id, { isActive: false }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.validateApiKey(created.key), false); +}); + +// ──────────────── getApiKeyMetadata ──────────────── + +test("getApiKeyMetadata returns null for null / undefined / empty", async () => { + await resetStorage(); + assert.equal(await apiKeys.getApiKeyMetadata(null), null); + assert.equal(await apiKeys.getApiKeyMetadata(undefined), null); + assert.equal(await apiKeys.getApiKeyMetadata(""), null); +}); + +test("getApiKeyMetadata returns env-key record for env key", async () => { + await resetStorage(); + const prev = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = "env-key-meta-001"; + try { + const meta = await apiKeys.getApiKeyMetadata("env-key-meta-001"); + assert.ok(meta !== null); + assert.equal(meta!.id, "env-key"); + assert.equal(meta!.name, "Environment Key"); + assert.ok(meta!.scopes.includes("manage")); + assert.equal(meta!.isActive, true); + } finally { + process.env.OMNIROUTE_API_KEY = prev; + } +}); + +test("getApiKeyMetadata returns metadata for valid key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Meta Key", "ma-009"); + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta !== null); + assert.equal(meta!.id, created.id); + assert.equal(meta!.name, "Meta Key"); + assert.equal(meta!.machineId, "ma-009"); + assert.deepEqual(meta!.allowedModels, []); + assert.equal(meta!.noLog, false); + assert.equal(meta!.isActive, true); +}); + +test("getApiKeyMetadata returns null for non-existent key", async () => { + await resetStorage(); + assert.equal(await apiKeys.getApiKeyMetadata("omni_meta_nonexistent"), null); +}); + +// ──────────────── isModelAllowedForKey ──────────────── + +test("isModelAllowedForKey returns true when no key provided", async () => { + await resetStorage(); + assert.equal(await apiKeys.isModelAllowedForKey(null, "gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(undefined, "gpt-4"), true); +}); + +test("isModelAllowedForKey returns false when no modelId provided", async () => { + await resetStorage(); + assert.equal(await apiKeys.isModelAllowedForKey("some-key", null), false); + assert.equal(await apiKeys.isModelAllowedForKey("some-key", undefined), false); +}); + +test("isModelAllowedForKey returns false for non-existent key", async () => { + await resetStorage(); + assert.equal(await apiKeys.isModelAllowedForKey("omni_bogus_key", "gpt-4"), false); +}); + +test("isModelAllowedForKey returns true when allowedModels is unrestricted (empty)", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Unrestricted", "ma-010"); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "claude-opus-4"), true); +}); + +test("isModelAllowedForKey exact match", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Exact", "ma-011"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["gpt-4", "claude-3"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "claude-3"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-5"), false); +}); + +test("isModelAllowedForKey prefix match (/*)", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Prefix", "ma-012"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/*"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-5"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "anthropic/claude-3"), false); +}); + +test("isModelAllowedForKey wildcard match", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Wildcard", "ma-013"); + // Pattern "gpt-*-turbo" should match "gpt-4-turbo" but not "gpt-4" + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["gpt-*-turbo"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4-turbo"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4"), false); +}); + +// ──────────────── updateApiKeyPermissions ──────────────── + +test("updateApiKeyPermissions updates name", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Old Name", "ma-020"); + const result = await apiKeys.updateApiKeyPermissions(created.id, { name: "New Name" }); + assert.equal(result, true); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.equal(loaded!.name, "New Name"); +}); + +test("updateApiKeyPermissions toggles isActive", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Toggle Key", "ma-021"); + assert.equal((await apiKeys.getApiKeyById(created.id))!.isActive, true); + await apiKeys.updateApiKeyPermissions(created.id, { isActive: false }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.isActive, false); + await apiKeys.updateApiKeyPermissions(created.id, { isActive: true }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.isActive, true); +}); + +test("updateApiKeyPermissions toggles noLog", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("NoLog Key", "ma-022"); + await apiKeys.updateApiKeyPermissions(created.id, { noLog: true }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.noLog, true); +}); + +test("updateApiKeyPermissions sets isBanned", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Ban Key", "ma-023"); + await apiKeys.updateApiKeyPermissions(created.id, { isBanned: true }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.isBanned, true); + await apiKeys.updateApiKeyPermissions(created.id, { isBanned: false }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.isBanned, false); +}); + +test("updateApiKeyPermissions sets accessSchedule", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Scheduled", "ma-024"); + const schedule = { + enabled: true, + from: "09:00", + until: "17:00", + days: [1, 2, 3, 4, 5], + tz: "America/New_York", + }; + await apiKeys.updateApiKeyPermissions(created.id, { accessSchedule: schedule }); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.deepEqual(loaded!.accessSchedule, schedule); +}); + +test("updateApiKeyPermissions clears accessSchedule with null", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Clear Schedule", "ma-025"); + await apiKeys.updateApiKeyPermissions(created.id, { + accessSchedule: { enabled: true, from: "00:00", until: "23:59", days: [0], tz: "UTC" }, + }); + assert.ok((await apiKeys.getApiKeyById(created.id))!.accessSchedule !== null); + await apiKeys.updateApiKeyPermissions(created.id, { accessSchedule: null }); + assert.equal((await apiKeys.getApiKeyById(created.id))!.accessSchedule, null); +}); + +test("updateApiKeyPermissions sets rateLimits", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Rate Limited", "ma-026"); + const limits = [{ limit: 100, window: 60 }, { limit: 1000, window: 3600 }]; + await apiKeys.updateApiKeyPermissions(created.id, { rateLimits: limits }); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.deepEqual(loaded!.rateLimits, limits); +}); + +test("updateApiKeyPermissions sets maxSessions", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Session Limit", "ma-027"); + await apiKeys.updateApiKeyPermissions(created.id, { maxSessions: 5 }); + // maxSessions is not exposed in the getApiKeyById return type directly, + // but is part of metadata — verify via getApiKeyMetadata + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.equal(meta!.maxSessions, 5); +}); + +test("updateApiKeyPermissions with legacy array format sets allowedModels", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Legacy Format", "ma-028"); + const result = await apiKeys.updateApiKeyPermissions(created.id, ["model-a", "model-b"]); + assert.equal(result, true); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.deepEqual(loaded!.allowedModels, ["model-a", "model-b"]); +}); + +test("updateApiKeyPermissions updates scopes", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Scopes Update", "ma-029"); + const result = await apiKeys.updateApiKeyPermissions(created.id, { scopes: ["read", "manage"] }); + assert.equal(result, true); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.deepEqual(loaded!.scopes, ["read", "manage"]); +}); + +test("updateApiKeyPermissions empty update returns false", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("No Changes", "ma-030"); + const result = await apiKeys.updateApiKeyPermissions(created.id, {}); + assert.equal(result, false); +}); + +test("updateApiKeyPermissions non-existent id returns false", async () => { + await resetStorage(); + const result = await apiKeys.updateApiKeyPermissions("no-such-key", { + name: "Wont Work", + }); + assert.equal(result, false); +}); + +// ──────────────── deleteApiKey ──────────────── + +test("deleteApiKey deletes existing key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Delete Me", "ma-040"); + const result = await apiKeys.deleteApiKey(created.id); + assert.equal(result, true); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.equal(loaded, null); +}); + +test("deleteApiKey non-existent id returns false", async () => { + await resetStorage(); + const result = await apiKeys.deleteApiKey("no-such-id"); + assert.equal(result, false); +}); + +// ──────────────── revokeApiKey ──────────────── + +test("revokeApiKey revokes existing key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Revoke Me", "ma-050"); + const result = await apiKeys.revokeApiKey(created.id); + assert.equal(result, true); + const loaded = await apiKeys.getApiKeyById(created.id); + assert.ok(loaded !== null); + assert.equal(loaded!.isActive, false); + assert.ok(loaded!.revokedAt); +}); + +test("revokeApiKey non-existent id returns false", async () => { + await resetStorage(); + assert.equal(await apiKeys.revokeApiKey("no-such-id"), false); +}); + +test("revokeApiKey double revoke is idempotent", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Double Revoke", "ma-051"); + assert.equal(await apiKeys.revokeApiKey(created.id), true); + assert.equal(await apiKeys.revokeApiKey(created.id), true); +}); + +// ──────────────── setApiKeyExpiry ──────────────── + +test("setApiKeyExpiry sets expiry on existing key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Expiry Test", "ma-060"); + const result = await apiKeys.setApiKeyExpiry(created.id, "2030-12-31T23:59:59Z"); + assert.equal(result, true); + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.equal(meta!.expiresAt, "2030-12-31T23:59:59Z"); +}); + +test("setApiKeyExpiry clears expiry with null", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Clear Expiry", "ma-061"); + await apiKeys.setApiKeyExpiry(created.id, "2030-01-01T00:00:00Z"); + await apiKeys.setApiKeyExpiry(created.id, null); + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.equal(meta!.expiresAt, null); +}); + +test("setApiKeyExpiry non-existent id returns false", async () => { + await resetStorage(); + assert.equal(await apiKeys.setApiKeyExpiry("no-such-id", "2030-01-01T00:00:00Z"), false); +}); + +// ──────────────── regenerateApiKey ──────────────── + +test("regenerateApiKey regenerates key", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Regen Key", "ma-070"); + const oldKey = created.key; + const result = await apiKeys.regenerateApiKey(created.id); + assert.ok(result !== null); + assert.equal(result!.id, created.id); + assert.ok(result!.key); + assert.notEqual(result!.key, oldKey); +}); + +test("regenerateApiKey non-existent id returns null", async () => { + await resetStorage(); + assert.equal(await apiKeys.regenerateApiKey("no-such-id"), null); +}); + +// ──────────────── clearApiKeyCaches / resetApiKeyState ──────────────── + +test("clearApiKeyCaches and resetApiKeyState do not throw", async () => { + await resetStorage(); + // Fill caches with some activity + const created = await apiKeys.createApiKey("Cache Test", "ma-080"); + await apiKeys.validateApiKey(created.key); + await apiKeys.getApiKeyMetadata(created.key); + + // Should not throw + apiKeys.clearApiKeyCaches(); + apiKeys.resetApiKeyState(); + assert.ok(true); +}); + +// ──────────────── matchesWildcardPattern (unit-level) ──────────────── + +test("matchesWildcardPattern exact match", async () => { + // Directly test the underlying function logic via isModelAllowedForKey + await resetStorage(); + const created = await apiKeys.createApiKey("Wild Exact", "ma-090"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/gpt-4"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/claude"), false); +}); + +test("isModelAllowedForKey segment count mismatch via matchesWildcardPattern", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Wild Segments", "ma-091"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/gpt-*"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4/sub"), false); +}); + +test("matchesWildcardPattern wildcard within segment", async () => { + // Pattern "gpt-*-turbo" should match "gpt-4-turbo" but not "gpt-4" + await resetStorage(); + const created = await apiKeys.createApiKey("Wild Within", "ma-092"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["gpt-*-turbo"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4-turbo"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-5-turbo"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "gpt-4"), false); +}); + +test("matchesWildcardPattern pattern with only *", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Wild Star", "ma-093"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/*"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/anything"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/"), true); +}); + +test("matchesWildcardPattern multiple segments with wildcards", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Wild Multi", "ma-094"); + await apiKeys.updateApiKeyPermissions(created.id, { allowedModels: ["openai/*/turbo"] }); + apiKeys.resetApiKeyState(); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4/turbo"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-5/turbo"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4"), false); +}); diff --git a/tests/unit/db-core.test.ts b/tests/unit/db-core.test.ts new file mode 100644 index 0000000000..b198a780a7 --- /dev/null +++ b/tests/unit/db-core.test.ts @@ -0,0 +1,361 @@ +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"; +import { pathToFileURL } from "node:url"; + +const serial = { concurrency: false }; + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function removePath(targetPath: string) { + fs.rmSync(targetPath, { recursive: true, force: true }); +} + +const originalEnv = { + DATA_DIR: process.env.DATA_DIR, + NEXT_PHASE: process.env.NEXT_PHASE, +}; + +function restoreEnv() { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function cleanupGlobalDb() { + try { + if ((globalThis as any).__omnirouteDb?.open) { + (globalThis as any).__omnirouteDb.close(); + } + } catch {} + delete (globalThis as any).__omnirouteDb; +} + +async function importFresh(modulePath: string) { + const url = pathToFileURL(path.resolve(modulePath)).href; + return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +async function withEnv(overrides: Record, fn: () => Promise) { + const snapshot: Record = {}; + for (const key of Object.keys(overrides)) { + snapshot[key] = process.env[key]; + const value = overrides[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +// ──────────────── Pure Utility Functions ──────────────── + +test("toSnakeCase converts camelCase to snake_case", () => { + // Import once at module level for pure functions + // We use a lazy import pattern +}); + +// We'll use a single import for the module +let core: any; + +test.before(async () => { + const dataDir = makeTempDir("omniroute-db-core-utils-"); + try { + await withEnv({ DATA_DIR: dataDir }, async () => { + core = await importFresh("src/lib/db/core.ts"); + // Initialize DB so DB functions work too + core.getDbInstance(); + }); + } catch { + removePath(dataDir); + } +}); + +test.after(() => { + try { + core?.resetDbInstance(); + } catch {} + cleanupGlobalDb(); + restoreEnv(); +}); + +// ─── toSnakeCase ─────────────────────────────────────── + +test("toSnakeCase: basic camelCase conversion", () => { + assert.equal(core.toSnakeCase("apiKey"), "api_key"); + assert.equal(core.toSnakeCase("isActive"), "is_active"); + assert.equal(core.toSnakeCase("providerName"), "provider_name"); +}); + +test("toSnakeCase: empty string and single word", () => { + assert.equal(core.toSnakeCase(""), ""); + assert.equal(core.toSnakeCase("name"), "name"); + assert.equal(core.toSnakeCase("id"), "id"); +}); + +test("toSnakeCase: consecutive uppercase (acronyms)", () => { + // The implementation inserts _ before each uppercase including the first + // character, then lowercases. So "APIKey" → "_a_p_i_key". + assert.equal(core.toSnakeCase("APIKey"), "_a_p_i_key"); + assert.equal(core.toSnakeCase("DBConnection"), "_d_b_connection"); +}); + +test("toSnakeCase: already snake_case stays same", () => { + assert.equal(core.toSnakeCase("already_snake"), "already_snake"); + assert.equal(core.toSnakeCase("already_snake_case"), "already_snake_case"); +}); + +test("toSnakeCase: mixed snake and camel", () => { + assert.equal(core.toSnakeCase("provider_name"), "provider_name"); + assert.equal(core.toSnakeCase("provider_Name"), "provider__name"); +}); + +// ─── toCamelCase ─────────────────────────────────────── + +test("toCamelCase: basic snake_case conversion", () => { + assert.equal(core.toCamelCase("api_key"), "apiKey"); + assert.equal(core.toCamelCase("is_active"), "isActive"); + assert.equal(core.toCamelCase("provider_name"), "providerName"); +}); + +test("toCamelCase: empty string and single word", () => { + assert.equal(core.toCamelCase(""), ""); + assert.equal(core.toCamelCase("name"), "name"); + assert.equal(core.toCamelCase("id"), "id"); +}); + +test("toCamelCase: multiple underscores", () => { + assert.equal(core.toCamelCase("foo_bar_baz"), "fooBarBaz"); + assert.equal(core.toCamelCase("a_b_c"), "aBC"); +}); + +test("toCamelCase: leading underscore", () => { + assert.equal(core.toCamelCase("_private"), "Private"); +}); + +test("toCamelCase: trailing underscore is preserved", () => { + // Regex _([a-z]) doesn't match trailing underscore (no char after it) + assert.equal(core.toCamelCase("trailing_"), "trailing_"); +}); + +// ─── objToSnake ──────────────────────────────────────── + +test("objToSnake: converts object keys to snake_case", () => { + const input = { apiKey: "sk-test", isActive: true, providerName: "openai" }; + const result = core.objToSnake(input); + assert.deepEqual(result, { api_key: "sk-test", is_active: true, provider_name: "openai" }); +}); + +test("objToSnake: null/undefined/non-object input", () => { + assert.equal(core.objToSnake(null), null); + assert.equal(core.objToSnake(undefined), undefined); + assert.equal(core.objToSnake("string"), "string"); + assert.equal(core.objToSnake(42), 42); +}); + +test("objToSnake: empty object", () => { + assert.deepEqual(core.objToSnake({}), {}); +}); + +test("objToSnake: nested values preserved as-is (shallow)", () => { + const input = { nestedObj: { innerKey: "val" }, items: [1, 2, 3] }; + const result = core.objToSnake(input); + assert.deepEqual(result, { nested_obj: { innerKey: "val" }, items: [1, 2, 3] }); +}); + +// ─── rowToCamel ──────────────────────────────────────── + +test("rowToCamel: converts snake_case row to camelCase", () => { + const row = { api_key: "sk-test", is_active: 1, provider_name: "openai" }; + const result = core.rowToCamel(row); + assert.deepEqual(result, { apiKey: "sk-test", isActive: true, providerName: "openai" }); +}); + +test("rowToCamel: null/undefined row returns null", () => { + assert.equal(core.rowToCamel(null), null); + assert.equal(core.rowToCamel(undefined), null); +}); + +test("rowToCamel: isActive boolean conversion (1/0)", () => { + assert.deepEqual(core.rowToCamel({ is_active: 1 }), { isActive: true }); + assert.deepEqual(core.rowToCamel({ is_active: 0 }), { isActive: false }); + assert.deepEqual(core.rowToCamel({ is_active: true }), { isActive: true }); + assert.deepEqual(core.rowToCamel({ is_active: false }), { isActive: false }); +}); + +test("rowToCamel: rateLimitProtection boolean conversion", () => { + assert.deepEqual(core.rowToCamel({ rate_limit_protection: 1 }), { rateLimitProtection: true }); + assert.deepEqual(core.rowToCamel({ rate_limit_protection: 0 }), { rateLimitProtection: false }); +}); + +test("rowToCamel: providerSpecificData JSON string parsing", () => { + const data = JSON.stringify({ org: "my-org" }); + const result = core.rowToCamel({ provider_specific_data: data }); + assert.deepEqual(result, { providerSpecificData: { org: "my-org" } }); +}); + +test("rowToCamel: providerSpecificData invalid JSON", () => { + const result = core.rowToCamel({ provider_specific_data: "not-json" }); + assert.equal(result?.providerSpecificData, "not-json"); +}); + +test("rowToCamel: providerSpecificData non-string passes through", () => { + const result = core.rowToCamel({ provider_specific_data: 42 }); + assert.equal(result?.providerSpecificData, 42); +}); + +test("rowToCamel: _json suffix columns parsed into base key", () => { + const result = core.rowToCamel({ quota_window_thresholds_json: JSON.stringify([1, 2, 3]) }); + assert.deepEqual(result, { quotaWindowThresholds: [1, 2, 3] }); +}); + +test("rowToCamel: _json suffix with invalid JSON sets null", () => { + const result = core.rowToCamel({ quota_window_thresholds_json: "broken" }); + assert.deepEqual(result, { quotaWindowThresholds: null }); +}); + +test("rowToCamel: ordinary fields pass through unchanged", () => { + const result = core.rowToCamel({ name: "test", created_at: "2025-01-01" }); + assert.deepEqual(result, { name: "test", createdAt: "2025-01-01" }); +}); + +test("rowToCamel: empty row returns empty object", () => { + assert.deepEqual(core.rowToCamel({}), {}); +}); + +// ─── cleanNulls ──────────────────────────────────────── + +test("cleanNulls: removes null and undefined values", () => { + const result = core.cleanNulls({ a: 1, b: null, c: "keep", d: undefined, e: 0, f: "" }); + assert.deepEqual(result, { a: 1, c: "keep", e: 0, f: "" }); +}); + +test("cleanNulls: all values kept", () => { + const result = core.cleanNulls({ a: 1, b: "x", c: false }); + assert.deepEqual(result, { a: 1, b: "x", c: false }); +}); + +test("cleanNulls: empty object returns empty object", () => { + assert.deepEqual(core.cleanNulls({}), {}); +}); + +test("cleanNulls: all null/undefined returns empty object", () => { + assert.deepEqual(core.cleanNulls({ a: null, b: undefined }), {}); +}); + +// ─── getDriverInfo ───────────────────────────────────── + +test("getDriverInfo returns null (setDriverInfo never called)", () => { + // setDriverInfo() exists but is never called in core.ts, so getDriverInfo + // always returns null until/unless a caller invokes setDriverInfo(). + assert.equal(core.getDriverInfo(), null); +}); + +// ─── DB Functions (autoVacuum, pageSize, cacheSize) ─── + +test("setAutoVacuum and getAutoVacuumMode round-trip", serial, () => { + // Get current + const originalMode = core.getAutoVacuumMode(); + + // Set to NONE first + core.setAutoVacuum("NONE"); + assert.equal(core.getAutoVacuumMode(), "NONE"); + + // Set to FULL + core.setAutoVacuum("FULL"); + assert.equal(core.getAutoVacuumMode(), "FULL"); + + // Set to INCREMENTAL + core.setAutoVacuum("INCREMENTAL"); + assert.equal(core.getAutoVacuumMode(), "INCREMENTAL"); + + // Restore original + core.setAutoVacuum(originalMode); + assert.equal(core.getAutoVacuumMode(), originalMode); +}); + +test("setAutoVacuum same mode is idempotent", serial, () => { + const mode = core.getAutoVacuumMode(); + // Calling again with same mode should not throw + core.setAutoVacuum(mode); + assert.equal(core.getAutoVacuumMode(), mode); +}); + +test("runManualVacuum succeeds", serial, () => { + const result = core.runManualVacuum(); + assert.equal(result.success, true); + assert.equal(typeof result.duration, "number"); + assert.ok(result.duration >= 0); + assert.equal(result.error, undefined); +}); + +test("setPageSize round-trip", serial, () => { + // Capture current page_size so we can restore it + // We'll set to a known value, verify, then set back + // Note: page_size can only be set if the DB is empty or after VACUUM + // The implementation calls VACUUM after setting, which is safe + const testPageSize = 4096; + core.setPageSize(testPageSize); + // We can't read it back directly via exported function, but it shouldn't throw + // Verify by running it again (idempotent) + core.setPageSize(testPageSize); +}); + +test("setCacheSize round-trip", serial, () => { + core.setCacheSize(16384); + // Verify idempotent + core.setCacheSize(16384); +}); + +// ─── Edge Cases ──────────────────────────────────────── + +test("toSnakeCase and toCamelCase are inverses for simple cases", () => { + const pairs = [ + ["apiKey", "api_key"], + ["isActive", "is_active"], + ["providerName", "provider_name"], + ["createdAt", "created_at"], + ["updatedAt", "updated_at"], + ]; + for (const [camel, snake] of pairs) { + assert.equal(core.toSnakeCase(camel), snake); + assert.equal(core.toCamelCase(snake), camel); + } +}); + +test("cleanNulls with falsy values preserves 0, false, empty string", () => { + const result = core.cleanNulls({ zero: 0, falseVal: false, emptyStr: "", nil: null }); + assert.deepEqual(result, { zero: 0, falseVal: false, emptyStr: "" }); +}); + +test("objToSnake returns same object reference for primitives", () => { + const num = 42; + assert.equal(core.objToSnake(num), 42); +}); + +test("rowToCamel with isActive=1/non-1 edge cases", () => { + assert.deepEqual(core.rowToCamel({ is_active: 1 }), { isActive: true }); + assert.deepEqual(core.rowToCamel({ is_active: 0 }), { isActive: false }); + assert.deepEqual(core.rowToCamel({ is_active: 2 }), { isActive: false }); + assert.deepEqual(core.rowToCamel({ is_active: "yes" }), { isActive: false }); +}); diff --git a/tests/unit/db-domainState-crud.test.ts b/tests/unit/db-domainState-crud.test.ts new file mode 100644 index 0000000000..c02be9060c --- /dev/null +++ b/tests/unit/db-domainState-crud.test.ts @@ -0,0 +1,362 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-domainstate-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const ds = await import("../../src/lib/db/domainState.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch { + await new Promise((r) => setTimeout(r, 50 * (attempt + 1))); + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); +} + +await resetStorage(); + +// ──────────────── Fallback Chains ──────────────── + +test("saveFallbackChain and loadFallbackChain round-trip", async () => { + await resetStorage(); + const model = "gpt-4"; + const chain = [ + { provider: "openai", priority: 1, enabled: true }, + { provider: "anthropic", priority: 2, enabled: false }, + ]; + + ds.saveFallbackChain(model, chain); + const loaded = ds.loadFallbackChain(model); + assert.deepEqual(loaded, chain); +}); + +test("loadFallbackChain returns null for missing model", async () => { + await resetStorage(); + const result = ds.loadFallbackChain("nonexistent"); + assert.equal(result, null); +}); + +test("loadAllFallbackChains returns all chains", async () => { + await resetStorage(); + ds.saveFallbackChain("model-a", [{ provider: "p1", priority: 1, enabled: true }]); + ds.saveFallbackChain("model-b", [{ provider: "p2", priority: 2, enabled: false }]); + + const all = ds.loadAllFallbackChains(); + assert.ok("model-a" in all); + assert.ok("model-b" in all); + assert.equal((all["model-a"] as any[]).length, 1); +}); + +test("deleteFallbackChain removes a chain", async () => { + await resetStorage(); + ds.saveFallbackChain("to-delete", [{ provider: "p", priority: 1, enabled: true }]); + assert.equal(ds.deleteFallbackChain("to-delete"), true); + assert.equal(ds.loadFallbackChain("to-delete"), null); +}); + +test("deleteFallbackChain returns false when chain does not exist", async () => { + await resetStorage(); + assert.equal(ds.deleteFallbackChain("never-existed"), false); +}); + +test("deleteAllFallbackChains clears everything", async () => { + await resetStorage(); + ds.saveFallbackChain("a", [{ provider: "p", priority: 1, enabled: true }]); + ds.saveFallbackChain("b", [{ provider: "p", priority: 1, enabled: true }]); + ds.deleteAllFallbackChains(); + assert.deepEqual(ds.loadAllFallbackChains(), {}); +}); + +// ──────────────── Budgets ──────────────── + +test("saveBudget and loadBudget round-trip", async () => { + await resetStorage(); + ds.saveBudget("key-1", { + dailyLimitUsd: 10, + weeklyLimitUsd: 50, + monthlyLimitUsd: 200, + warningThreshold: 0.8, + resetInterval: "daily", + resetTime: "08:00", + budgetResetAt: 1000, + lastBudgetResetAt: 500, + warningEmittedAt: 900, + warningPeriodStart: 800, + }); + + const loaded = ds.loadBudget("key-1"); + assert.ok(loaded !== null); + assert.equal(loaded.dailyLimitUsd, 10); + assert.equal(loaded.weeklyLimitUsd, 50); + assert.equal(loaded.monthlyLimitUsd, 200); + assert.equal(loaded.warningThreshold, 0.8); + assert.equal(loaded.resetInterval, "daily"); + assert.equal(loaded.resetTime, "08:00"); + assert.equal(loaded.budgetResetAt, 1000); + assert.equal(loaded.lastBudgetResetAt, 500); + assert.equal(loaded.warningEmittedAt, 900); + assert.equal(loaded.warningPeriodStart, 800); +}); + +test("loadBudget returns null for missing key", () => { + assert.equal(ds.loadBudget("no-such-key"), null); +}); + +test("saveBudget with minimal fields uses defaults", async () => { + await resetStorage(); + ds.saveBudget("key-minimal", {}); + const loaded = ds.loadBudget("key-minimal"); + assert.ok(loaded !== null); + assert.equal(loaded.dailyLimitUsd, 0); + assert.equal(loaded.warningThreshold, 0.8); + assert.equal(loaded.resetInterval, "daily"); + assert.equal(loaded.resetTime, "00:00"); + assert.equal(loaded.budgetResetAt, null); + assert.equal(loaded.lastBudgetResetAt, null); +}); + +test("loadAllBudgets returns all budget configs", async () => { + await resetStorage(); + ds.saveBudget("key-a", { dailyLimitUsd: 5 }); + ds.saveBudget("key-b", { dailyLimitUsd: 10 }); + + const all = ds.loadAllBudgets(); + assert.equal(Object.keys(all).length, 2); + assert.equal(all["key-a"].dailyLimitUsd, 5); + assert.equal(all["key-b"].dailyLimitUsd, 10); +}); + +test("saveBudgetResetLog and loadBudgetResetLogs", async () => { + await resetStorage(); + ds.saveBudget("budget-key", { dailyLimitUsd: 10 }); + + const now = Date.now(); + ds.saveBudgetResetLog({ + apiKeyId: "budget-key", + resetInterval: "daily", + previousSpend: 8, + resetAt: now, + nextResetAt: now + 86400000, + periodStart: now - 86400000, + periodEnd: now, + }); + + const logs = ds.loadBudgetResetLogs("budget-key"); + assert.equal(logs.length, 1); + assert.equal(logs[0].previousSpend, 8); + assert.equal(logs[0].resetInterval, "daily"); + + const noLogs = ds.loadBudgetResetLogs("no-such-key"); + assert.deepEqual(noLogs, []); +}); + +test("deleteBudget removes budget and reset logs", async () => { + await resetStorage(); + ds.saveBudget("del-key", { dailyLimitUsd: 10 }); + ds.saveBudgetResetLog({ apiKeyId: "del-key", resetInterval: "daily", previousSpend: 3, resetAt: 1, nextResetAt: 2, periodStart: 0, periodEnd: 1 }); + ds.deleteBudget("del-key"); + assert.equal(ds.loadBudget("del-key"), null); + assert.deepEqual(ds.loadBudgetResetLogs("del-key"), []); +}); + +// ──────────────── Cost History ──────────────── + +test("saveCostEntry and loadCostTotal", async () => { + await resetStorage(); + ds.saveCostEntry("cost-key", 1.5, 1000); + ds.saveCostEntry("cost-key", 2.5, 2000); + ds.saveCostEntry("cost-key", 3.0, 3000); + + const total = ds.loadCostTotal("cost-key", 1500); + assert.equal(total, 5.5); // 2.5 + 3.0 + + const all = ds.loadCostTotal("cost-key", 0); + assert.equal(all, 7.0); +}); + +test("loadCostTotal returns 0 for no entries", () => { + assert.equal(ds.loadCostTotal("no-key", 0), 0); +}); + +test("batchSaveCostEntries inserts multiple entries", async () => { + await resetStorage(); + ds.batchSaveCostEntries([ + { apiKeyId: "batch-key", cost: 1, timestamp: 100 }, + { apiKeyId: "batch-key", cost: 2, timestamp: 200 }, + ]); + assert.equal(ds.loadCostTotal("batch-key", 0), 3); +}); + +test("batchSaveCostEntries skips empty array", () => { + assert.doesNotThrow(() => ds.batchSaveCostEntries([])); +}); + +test("loadCostEntries returns entries in order", async () => { + await resetStorage(); + ds.saveCostEntry("ce-key", 1, 100); + ds.saveCostEntry("ce-key", 2, 200); + ds.saveCostEntry("ce-key", 3, 300); + + const entries = ds.loadCostEntries("ce-key", 150); + assert.equal(entries.length, 2); + assert.equal((entries[0] as any).cost, 2); + assert.equal((entries[1] as any).cost, 3); +}); + +test("loadCostEntriesInRange returns bounded entries", async () => { + await resetStorage(); + ds.saveCostEntry("range-key", 1, 100); + ds.saveCostEntry("range-key", 2, 200); + ds.saveCostEntry("range-key", 3, 300); + + const entries = ds.loadCostEntriesInRange("range-key", 150, 250); + assert.equal(entries.length, 1); + assert.equal((entries[0] as any).cost, 2); +}); + +test("cleanOldCostEntries deletes old entries", async () => { + await resetStorage(); + ds.saveCostEntry("clean-key", 1, 100); + ds.saveCostEntry("clean-key", 2, 200); + ds.saveCostEntry("clean-key", 3, 300); + + const deleted = ds.cleanOldCostEntries(250); + assert.equal(deleted, 2); // entries at 100 and 200 + assert.equal(ds.loadCostTotal("clean-key", 0), 3); +}); + +test("deleteCostEntries removes all for key", async () => { + await resetStorage(); + ds.saveCostEntry("del-cost", 5, 100); + ds.saveCostEntry("del-cost", 10, 200); + ds.deleteCostEntries("del-cost"); + assert.equal(ds.loadCostTotal("del-cost", 0), 0); +}); + +test("deleteAllCostData wipes budgets and cost data", async () => { + await resetStorage(); + ds.saveBudget("wipe-key", { dailyLimitUsd: 10 }); + ds.saveCostEntry("wipe-key", 5, 100); + ds.deleteAllCostData(); + assert.equal(ds.loadBudget("wipe-key"), null); + assert.equal(ds.loadCostTotal("wipe-key", 0), 0); +}); + +// ──────────────── Lockout State ──────────────── + +test("saveLockoutState and loadLockoutState round-trip", async () => { + await resetStorage(); + ds.saveLockoutState("user-1", { attempts: [100, 200, 300], lockedUntil: 9999999999999 }); + const loaded = ds.loadLockoutState("user-1"); + assert.ok(loaded !== null); + assert.deepEqual(loaded.attempts, [100, 200, 300]); + assert.equal(loaded.lockedUntil, 9999999999999); +}); + +test("loadLockoutState returns null for missing identifier", () => { + assert.equal(ds.loadLockoutState("no-such"), null); +}); + +test("saveLockoutState with null lockedUntil", async () => { + await resetStorage(); + ds.saveLockoutState("not-locked", { attempts: [], lockedUntil: null }); + const loaded = ds.loadLockoutState("not-locked"); + assert.ok(loaded !== null); + assert.deepEqual(loaded.attempts, []); + assert.equal(loaded.lockedUntil, null); +}); + +test("deleteLockoutState removes state", async () => { + await resetStorage(); + ds.saveLockoutState("del-lock", { attempts: [1], lockedUntil: null }); + ds.deleteLockoutState("del-lock"); + assert.equal(ds.loadLockoutState("del-lock"), null); +}); + +test("loadAllLockedIdentifiers returns only currently locked", async () => { + await resetStorage(); + ds.saveLockoutState("locked-now", { attempts: [1], lockedUntil: Date.now() + 3600000 }); + ds.saveLockoutState("expired", { attempts: [1], lockedUntil: Date.now() - 3600000 }); + ds.saveLockoutState("no-lock", { attempts: [], lockedUntil: null }); + + const locked = ds.loadAllLockedIdentifiers(); + assert.equal(locked.length, 1); + assert.equal(locked[0].identifier, "locked-now"); +}); + +// ──────────────── Circuit Breakers ──────────────── + +test("saveCircuitBreakerState and loadCircuitBreakerState round-trip", async () => { + await resetStorage(); + ds.saveCircuitBreakerState("cb-1", { + state: "OPEN", + failureCount: 5, + lastFailureTime: 1000, + options: { timeout: 30000 }, + }); + + const loaded = ds.loadCircuitBreakerState("cb-1"); + assert.ok(loaded !== null); + assert.equal(loaded.state, "OPEN"); + assert.equal(loaded.failureCount, 5); + assert.equal(loaded.lastFailureTime, 1000); + assert.deepEqual(loaded.options, { timeout: 30000 }); +}); + +test("loadCircuitBreakerState returns null for missing name", () => { + assert.equal(ds.loadCircuitBreakerState("no-such"), null); +}); + +test("saveCircuitBreakerState without options", async () => { + await resetStorage(); + ds.saveCircuitBreakerState("cb-simple", { + state: "CLOSED", + failureCount: 0, + lastFailureTime: null, + }); + const loaded = ds.loadCircuitBreakerState("cb-simple"); + assert.ok(loaded !== null); + assert.equal(loaded.state, "CLOSED"); + assert.equal(loaded.failureCount, 0); + assert.equal(loaded.lastFailureTime, null); + assert.equal(loaded.options, null); +}); + +test("loadAllCircuitBreakerStates returns all", async () => { + await resetStorage(); + ds.saveCircuitBreakerState("cb-a", { state: "HALF_OPEN", failureCount: 2, lastFailureTime: 500 }); + ds.saveCircuitBreakerState("cb-b", { state: "CLOSED", failureCount: 0, lastFailureTime: null }); + + const all = ds.loadAllCircuitBreakerStates(); + assert.equal(all.length, 2); + const names = all.map((r: any) => r.name).sort(); + assert.deepEqual(names, ["cb-a", "cb-b"]); +}); + +test("deleteCircuitBreakerState removes state", async () => { + await resetStorage(); + ds.saveCircuitBreakerState("del-cb", { state: "OPEN", failureCount: 1, lastFailureTime: 100 }); + ds.deleteCircuitBreakerState("del-cb"); + assert.equal(ds.loadCircuitBreakerState("del-cb"), null); +}); + +test("deleteAllCircuitBreakerStates clears everything", async () => { + await resetStorage(); + ds.saveCircuitBreakerState("a", { state: "OPEN", failureCount: 1, lastFailureTime: 100 }); + ds.saveCircuitBreakerState("b", { state: "CLOSED", failureCount: 0, lastFailureTime: null }); + ds.deleteAllCircuitBreakerStates(); + assert.deepEqual(ds.loadAllCircuitBreakerStates(), []); +}); diff --git a/tests/unit/db-registeredKeys-crud.test.ts b/tests/unit/db-registeredKeys-crud.test.ts new file mode 100644 index 0000000000..a9a3894062 --- /dev/null +++ b/tests/unit/db-registeredKeys-crud.test.ts @@ -0,0 +1,311 @@ +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-regkeys-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const rk = await import("../../src/lib/db/registeredKeys.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch { + await new Promise((r) => setTimeout(r, 50 * (attempt + 1))); + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); +} + +await resetStorage(); + +// ──────────────── issueRegisteredKey ──────────────── + +test("issueRegisteredKey creates a key and returns it with rawKey", async () => { + await resetStorage(); + const result = rk.issueRegisteredKey({ + name: "Test Key", + provider: "openai", + accountId: "acc-1", + }); + + assert.ok("rawKey" in result && !("idempotencyConflict" in result)); + assert.ok(result.rawKey.startsWith("ork_")); + assert.equal(result.name, "Test Key"); + assert.equal(result.provider, "openai"); + assert.equal(result.accountId, "acc-1"); + assert.equal(result.isActive, true); + assert.ok(result.id); + assert.ok(result.keyPrefix); +}); + +test("issueRegisteredKey with idempotency key", async () => { + await resetStorage(); + const first = rk.issueRegisteredKey({ + name: "Idempotent", + idempotencyKey: "idem-1", + }); + + const second = rk.issueRegisteredKey({ + name: "Should Conflict", + idempotencyKey: "idem-1", + }); + + assert.ok("idempotencyConflict" in second); + assert.equal(second.idempotencyConflict, true); + assert.equal(second.existing.id, (first as any).id); +}); + +test("issueRegisteredKey with expiresAt and budgets", async () => { + await resetStorage(); + const result = rk.issueRegisteredKey({ + name: "Budgeted Key", + provider: "anthropic", + expiresAt: "2027-01-01T00:00:00Z", + dailyBudget: 100, + hourlyBudget: 10, + }); + + assert.ok("rawKey" in result); + assert.equal(result.expiresAt, "2027-01-01T00:00:00Z"); + assert.equal(result.dailyBudget, 100); + assert.equal(result.hourlyBudget, 10); +}); + +test("issueRegisteredKey without provider skips provider quotas", async () => { + await resetStorage(); + const result = rk.issueRegisteredKey({ name: "No Provider Key" }); + assert.ok("rawKey" in result); +}); + +// ──────────────── getRegisteredKey ──────────────── + +test("getRegisteredKey returns key by id", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ name: "Get Me" }) as any; + const loaded = rk.getRegisteredKey(created.id); + assert.ok(loaded !== null); + assert.equal(loaded.name, "Get Me"); + assert.equal(loaded.id, created.id); +}); + +test("getRegisteredKey returns null for missing id", () => { + assert.equal(rk.getRegisteredKey("no-such-id"), null); +}); + +// ──────────────── listRegisteredKeys ──────────────── + +test("listRegisteredKeys returns all keys", async () => { + await resetStorage(); + rk.issueRegisteredKey({ name: "Key A", provider: "openai" }); + rk.issueRegisteredKey({ name: "Key B", provider: "anthropic" }); + + const all = rk.listRegisteredKeys(); + assert.equal(all.length, 2); +}); + +test("listRegisteredKeys filters by provider", async () => { + await resetStorage(); + rk.issueRegisteredKey({ name: "OA", provider: "openai" }); + rk.issueRegisteredKey({ name: "AN", provider: "anthropic" }); + + const filtered = rk.listRegisteredKeys({ provider: "openai" }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].name, "OA"); +}); + +test("listRegisteredKeys filters by accountId", async () => { + await resetStorage(); + rk.issueRegisteredKey({ name: "Acc1 Key", accountId: "acc-1" }); + rk.issueRegisteredKey({ name: "Acc2 Key", accountId: "acc-2" }); + + const filtered = rk.listRegisteredKeys({ accountId: "acc-1" }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].name, "Acc1 Key"); +}); + +// ──────────────── revokeRegisteredKey ──────────────── + +test("revokeRegisteredKey deactivates a key", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ name: "Revoke Me" }) as any; + assert.equal(created.isActive, true); + + const revoked = rk.revokeRegisteredKey(created.id); + assert.equal(revoked, true); + + const loaded = rk.getRegisteredKey(created.id); + assert.equal(loaded!.isActive, false); + assert.ok(loaded!.revokedAt); +}); + +test("revokeRegisteredKey returns false for already revoked or missing key", async () => { + await resetStorage(); + assert.equal(rk.revokeRegisteredKey("no-such-id"), false); + + const created = rk.issueRegisteredKey({ name: "To Revoke" }) as any; + rk.revokeRegisteredKey(created.id); + assert.equal(rk.revokeRegisteredKey(created.id), false); +}); + +// ──────────────── validateRegisteredKey ──────────────── + +test("validateRegisteredKey validates raw key by hash", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ name: "Validate Me" }) as any; + + const validated = rk.validateRegisteredKey(created.rawKey); + assert.ok(validated !== null); + assert.equal(validated.name, "Validate Me"); +}); + +test("validateRegisteredKey returns null for invalid key", () => { + assert.equal(rk.validateRegisteredKey("ork_invalid"), null); +}); + +test("validateRegisteredKey returns null for revoked key", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ name: "Soon Revoked" }) as any; + rk.revokeRegisteredKey(created.id); + assert.equal(rk.validateRegisteredKey(created.rawKey), null); +}); + +// ──────────────── incrementRegisteredKeyUsage ──────────────── + +test("incrementRegisteredKeyUsage bumps counters", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ + name: "Usage Test", + dailyBudget: 100, + hourlyBudget: 50, + }) as any; + + assert.equal(created.dailyUsed, 0); + assert.equal(created.hourlyUsed, 0); + + rk.incrementRegisteredKeyUsage(created.id); + const loaded = rk.getRegisteredKey(created.id); + assert.equal(loaded!.dailyUsed, 1); + assert.equal(loaded!.hourlyUsed, 1); +}); + +test("validateRegisteredKey respects budget limits", async () => { + await resetStorage(); + const created = rk.issueRegisteredKey({ + name: "Budget Limit", + dailyBudget: 3, + }) as any; + + assert.ok(rk.validateRegisteredKey(created.rawKey) !== null); + rk.incrementRegisteredKeyUsage(created.id); + rk.incrementRegisteredKeyUsage(created.id); + rk.incrementRegisteredKeyUsage(created.id); + // After 3 increments, daily_used == daily_budget == 3 + assert.equal(rk.validateRegisteredKey(created.rawKey), null); +}); + +// ──────────────── checkQuota ──────────────── + +test("checkQuota returns allowed true when no limits set", async () => { + await resetStorage(); + const result = rk.checkQuota("openai", "acc-1"); + assert.equal(result.allowed, true); +}); + +test("checkQuota returns allowed true with no provider or account", () => { + const result = rk.checkQuota("", ""); + assert.equal(result.allowed, true); +}); + +test("checkQuota rejects when hourly limit exceeded", async () => { + await resetStorage(); + rk.setProviderKeyLimit("limited-provider", { hourlyIssueLimit: 2 }); + rk.issueRegisteredKey({ name: "K1", provider: "limited-provider" }); + rk.issueRegisteredKey({ name: "K2", provider: "limited-provider" }); + + const result = rk.checkQuota("limited-provider"); + assert.equal(result.allowed, false); + assert.equal(result.errorCode, "PROVIDER_QUOTA_EXCEEDED"); +}); + +test("checkQuota rejects when max active keys exceeded", async () => { + await resetStorage(); + rk.setProviderKeyLimit("maxed-provider", { maxActiveKeys: 1 }); + rk.issueRegisteredKey({ name: "Only One", provider: "maxed-provider" }); + + const result = rk.checkQuota("maxed-provider"); + assert.equal(result.allowed, false); + assert.equal(result.errorCode, "MAX_ACTIVE_KEYS_EXCEEDED"); +}); + +test("checkQuota account-level rejection", async () => { + await resetStorage(); + rk.setAccountKeyLimit("limited-account", { dailyIssueLimit: 1 }); + rk.issueRegisteredKey({ name: "Only", accountId: "limited-account" }); + + const result = rk.checkQuota("", "limited-account"); + assert.equal(result.allowed, false); + assert.equal(result.errorCode, "ACCOUNT_QUOTA_EXCEEDED"); +}); + +// ──────────────── setProviderKeyLimit / getProviderKeyLimit ──────────────── + +test("setProviderKeyLimit and getProviderKeyLimit round-trip", async () => { + await resetStorage(); + rk.setProviderKeyLimit("test-provider", { + maxActiveKeys: 5, + dailyIssueLimit: 100, + hourlyIssueLimit: 20, + }); + + const limit = rk.getProviderKeyLimit("test-provider"); + assert.ok(limit !== null); + assert.equal(limit.provider, "test-provider"); + assert.equal(limit.maxActiveKeys, 5); + assert.equal(limit.dailyIssueLimit, 100); + assert.equal(limit.hourlyIssueLimit, 20); +}); + +test("getProviderKeyLimit returns null for unknown provider", () => { + assert.equal(rk.getProviderKeyLimit("no-such-provider"), null); +}); + +test("setProviderKeyLimit with partial limits", async () => { + await resetStorage(); + rk.setProviderKeyLimit("partial-provider", { maxActiveKeys: 3 }); + const limit = rk.getProviderKeyLimit("partial-provider"); + assert.equal(limit!.maxActiveKeys, 3); + assert.equal(limit!.dailyIssueLimit, null); + assert.equal(limit!.hourlyIssueLimit, null); +}); + +// ──────────────── setAccountKeyLimit / getAccountKeyLimit ──────────────── + +test("setAccountKeyLimit and getAccountKeyLimit round-trip", async () => { + await resetStorage(); + rk.setAccountKeyLimit("test-account", { + maxActiveKeys: 10, + dailyIssueLimit: 200, + hourlyIssueLimit: 50, + }); + + const limit = rk.getAccountKeyLimit("test-account"); + assert.ok(limit !== null); + assert.equal(limit.accountId, "test-account"); + assert.equal(limit.maxActiveKeys, 10); + assert.equal(limit.dailyIssueLimit, 200); + assert.equal(limit.hourlyIssueLimit, 50); +}); + +test("getAccountKeyLimit returns null for unknown account", () => { + assert.equal(rk.getAccountKeyLimit("no-such-account"), null); +}); diff --git a/tests/unit/usage-utils.test.ts b/tests/unit/usage-utils.test.ts new file mode 100644 index 0000000000..79aadb7162 --- /dev/null +++ b/tests/unit/usage-utils.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for pure utility functions exported via usage.__testing + * + * Covers: parseResetTime, formatGitHubQuotaSnapshot, inferGitHubPlanName, + * getMiniMaxPlanLabel, inferMiniMaxPlanLabelFromTotals, + * extractCodeAssistSubscriptionTier, extractCodeAssistOnboardTierId. + * + * These are pure functions (no fetch, no DB) — run without DATA_DIR override. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +process.env.NODE_ENV = "test"; + +const usage = await import("../../open-sse/services/usage.ts"); +const { __testing } = usage; + +/* ------------------------------------------------------------------ */ +/* parseResetTime */ +/* ------------------------------------------------------------------ */ +describe("parseResetTime", () => { + it("returns null for null / undefined", () => { + assert.equal(__testing.parseResetTime(null), null); + assert.equal(__testing.parseResetTime(undefined), null); + }); + + it("returns null for epoch-zero", () => { + assert.equal(__testing.parseResetTime(0), null); + assert.equal(__testing.parseResetTime("1970-01-01T00:00:00.000Z"), null); + }); + + it("parses a number as ms timestamp (value > 1e12)", () => { + const ms = 1_700_000_000_000; + const out = __testing.parseResetTime(ms); + assert.equal(out, new Date(ms).toISOString()); + }); + + it("parses a number as seconds (value < 1e12)", () => { + const sec = 1_700_000_000; // < 1e12 + const out = __testing.parseResetTime(sec); + assert.equal(out, new Date(sec * 1000).toISOString()); + }); + + it("parses an ISO date string", () => { + const iso = "2026-06-15T12:00:00.000Z"; + assert.equal(__testing.parseResetTime(iso), new Date(iso).toISOString()); + }); + + it("parses a Date object", () => { + const d = new Date("2026-07-01T00:00:00Z"); + assert.equal(__testing.parseResetTime(d), d.toISOString()); + }); + + it("returns null for non-date values (objects, booleans)", () => { + assert.equal(__testing.parseResetTime({}), null); + assert.equal(__testing.parseResetTime(true), null); + }); + + it("returns null for invalid date strings", () => { + assert.equal(__testing.parseResetTime("not-a-date"), null); + }); +}); + +/* ------------------------------------------------------------------ */ +/* formatGitHubQuotaSnapshot */ +/* ------------------------------------------------------------------ */ +describe("formatGitHubQuotaSnapshot", () => { + it("returns null for empty object", () => { + assert.equal(__testing.formatGitHubQuotaSnapshot({}), null); + }); + + it("builds quota from snapshot with all fields", () => { + const snap = { + entitlement: 1000, + used: 300, + remaining: 700, + percent_remaining: 70, + }; + const q = __testing.formatGitHubQuotaSnapshot(snap, "2026-07-01T00:00:00.000Z"); + assert.equal(q.total, 1000); + assert.equal(q.used, 300); + assert.equal(q.remaining, 700); + assert.equal(q.remainingPercentage, 70); + assert.equal(q.resetAt, "2026-07-01T00:00:00.000Z"); + assert.equal(q.unlimited, false); + }); + + it("uses entitlement when total is missing", () => { + const snap = { entitlement: 500, remaining: 0.5, percent_remaining: 50 }; + const q = __testing.formatGitHubQuotaSnapshot(snap); + assert.equal(q.total, 500); + assert.ok(q.remaining !== undefined); + }); + + it("detects unlimited plan", () => { + const snap = { entitlement: 0, total: 0, unlimited: true }; + const q = __testing.formatGitHubQuotaSnapshot(snap); + assert.equal(q.unlimited, true); + assert.equal(q.total, 0); // total is 0 when percent_remaining is missing + }); + + it("clamps negative values to 0", () => { + const snap = { used: -10, entitlement: 100, remaining: -5, percent_remaining: -1 }; + const q = __testing.formatGitHubQuotaSnapshot(snap); + assert.equal(q.used, 0); + assert.equal(q.remaining, 0); + assert.equal(q.remainingPercentage, 0); + }); + + it("computes missing used from total - remaining", () => { + const snap = { entitlement: 200, remaining: 50 }; + const q = __testing.formatGitHubQuotaSnapshot(snap); + assert.equal(q.used, 150); // 200 - 50 + }); + + it("computes missing remaining from total - used", () => { + const snap = { used: 30, entitlement: 100 }; + const q = __testing.formatGitHubQuotaSnapshot(snap); + assert.equal(q.remaining, 70); + }); +}); + +/* ------------------------------------------------------------------ */ +/* inferGitHubPlanName */ +/* ------------------------------------------------------------------ */ +describe("inferGitHubPlanName", () => { + it("detects Copilot Pro+ from combined string", () => { + const data = { copilot_plan: "PRO_PLUS" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Pro+"); + }); + + it("detects Copilot Enterprise", () => { + const data = { copilot_plan: "ENTERPRISE" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Enterprise"); + }); + + it("detects Copilot Business", () => { + const data = { copilot_plan: "BUSINESS" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Business"); + }); + + it("detects Copilot Student", () => { + const data = { copilot_plan: "STUDENT" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Student"); + }); + + it("detects Copilot Free", () => { + const data = { copilot_plan: "FREE" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Free"); + }); + + it("detects Copilot Pro", () => { + const data = { copilot_plan: "PRO" }; + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Pro"); + }); + + it("infers Pro+ from premiumTotal >= 1400", () => { + const data = { copilot_plan: "INDIVIDUAL" }; + const premium = { used: 0, total: 1500, remaining: 1500, remainingPercentage: 100, unlimited: false }; + assert.equal(__testing.inferGitHubPlanName(data, premium), "Copilot Pro+"); + }); + + it("infers Enterprise from premiumTotal >= 900", () => { + const data = { copilot_plan: "INDIVIDUAL" }; + const premium = { used: 0, total: 900, remaining: 900, remainingPercentage: 100, unlimited: false }; + assert.equal(__testing.inferGitHubPlanName(data, premium), "Copilot Enterprise"); + }); + + it("infers Pro when premiumTotal >= 250 and combined has INDIVIDUAL", () => { + const data = { copilot_plan: "INDIVIDUAL" }; + const premium = { used: 0, total: 300, remaining: 300, remainingPercentage: 100, unlimited: false }; + assert.equal(__testing.inferGitHubPlanName(data, premium), "Copilot Pro"); + }); + + it("returns 'GitHub Copilot' fallback when nothing matches", () => { + const data = {}; + assert.equal(__testing.inferGitHubPlanName(data, null), "GitHub Copilot"); + }); + + it("falls back to sku label when planText is empty", () => { + const data = { access_type_sku: "BUSINESS_SPO" }; + // "BUSINESS_SPO" upper-cased matches "BUSINESS" check first + assert.equal(__testing.inferGitHubPlanName(data, null), "Copilot Business"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* getMiniMaxPlanLabel */ +/* ------------------------------------------------------------------ */ +describe("getMiniMaxPlanLabel", () => { + it("returns cleaned title from payload", () => { + const payload = { current_subscribe_title: "MiniMax Coding Plan Pro" }; + assert.equal(__testing.getMiniMaxPlanLabel(payload), "Pro"); + }); + + it("removes 'minimax ' prefix and 'coding plan' text", () => { + const payload = { plan: "MiniMax Coding Plan Ultra" }; + assert.equal(__testing.getMiniMaxPlanLabel(payload), "Ultra"); + }); + + it("calls inferMiniMaxPlanLabelFromTotals when no title present", () => { + const models = [{ current_interval_total_count: 500, current_weekly_total_count: 200 }]; + const label = __testing.getMiniMaxPlanLabel({}, models); + assert.ok(typeof label === "string"); + assert.ok(label.length > 0); + }); + + it("returns 'Coding Plan' fallback when nothing matches", () => { + assert.equal(__testing.getMiniMaxPlanLabel({}), "Coding Plan"); + }); + + it("picks first non-empty string from multiple candidate fields", () => { + const payload = { combo_title: "", plan_name: "MiniMax Turbo", plan: "" }; + const label = __testing.getMiniMaxPlanLabel(payload); + assert.equal(label, "Turbo"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* inferMiniMaxPlanLabelFromTotals */ +/* ------------------------------------------------------------------ */ +describe("inferMiniMaxPlanLabelFromTotals", () => { + it("returns 'Max' for totals >= 15K", () => { + const models = [{ current_interval_total_count: 20_000 }]; + assert.equal(__testing.inferMiniMaxPlanLabelFromTotals(models), "Max"); + }); + + it("returns 'Plus' for totals >= 4.5K but < 15K", () => { + const models = [{ current_interval_total_count: 5_000 }]; + assert.equal(__testing.inferMiniMaxPlanLabelFromTotals(models), "Plus"); + }); + + it("returns 'Starter' for totals >= 1.5K but < 4.5K", () => { + const models = [{ current_interval_total_count: 2_000 }]; + assert.equal(__testing.inferMiniMaxPlanLabelFromTotals(models), "Starter"); + }); + + it("returns null when models array is empty", () => { + assert.equal(__testing.inferMiniMaxPlanLabelFromTotals([]), null); + }); +}); + +/* ------------------------------------------------------------------ */ +/* getGeminiCliPlanLabel */ +/* ------------------------------------------------------------------ */ +describe("getGeminiCliPlanLabel", () => { + it("returns a string label", () => { + const label = __testing.getGeminiCliPlanLabel(); + assert.ok(typeof label === "string"); + assert.ok(label.length > 0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* getAntigravityPlanLabel */ +/* ------------------------------------------------------------------ */ +describe("getAntigravityPlanLabel", () => { + it("returns a string label", () => { + const label = __testing.getAntigravityPlanLabel(); + assert.ok(typeof label === "string"); + assert.ok(label.length > 0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* extractCodeAssist helpers */ +/* ------------------------------------------------------------------ */ +describe("extractCodeAssistOnboardTierId", () => { + it("extracts tier id from paidTier", () => { + const sub = { paidTier: { id: "pro-tier" } }; + assert.equal(__testing.extractCodeAssistOnboardTierId(sub), "pro-tier"); + }); + + it("extracts tier id from currentTier when paidTier is absent", () => { + const sub = { currentTier: { id: "starter-tier" } }; + assert.equal(__testing.extractCodeAssistOnboardTierId(sub), "starter-tier"); + }); + + it("returns 'legacy-tier' when no tier data is present", () => { + assert.equal(__testing.extractCodeAssistOnboardTierId({}), "legacy-tier"); + }); +}); + +describe("extractCodeAssistSubscriptionTier", () => { + it("reads tier name from paidTier.name", () => { + const info = { paidTier: { name: "ULTRA" } }; + assert.equal(__testing.extractCodeAssistSubscriptionTier(info), "ULTRA"); + }); + + it("reads tier from currentTier.name", () => { + const info = { currentTier: { name: "Pro" } }; + assert.equal(__testing.extractCodeAssistSubscriptionTier(info), "Pro"); + }); + + it("returns null when nothing matches", () => { + assert.equal(__testing.extractCodeAssistSubscriptionTier({}), null); + }); +});