diff --git a/src/__tests__/keys-coverage.test.ts b/src/__tests__/keys-coverage.test.ts index ece0678..71df45c 100644 --- a/src/__tests__/keys-coverage.test.ts +++ b/src/__tests__/keys-coverage.test.ts @@ -1,129 +1,155 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; -// Unmock keys service — we want real implementation +// Override the global setup.ts mock for keys — we need the REAL implementation vi.unmock("../services/keys.js"); -// DB is still mocked by setup.ts +// Keep db mocked (setup.ts already does this, but be explicit about our mock) +vi.mock("../services/db.js", () => ({ + default: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + pool: { query: vi.fn(), connect: vi.fn(), on: vi.fn(), end: vi.fn() }, + queryWithRetry: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connectWithRetry: vi.fn().mockResolvedValue(undefined), + initDatabase: vi.fn().mockResolvedValue(undefined), + cleanupStaleData: vi.fn(), + isTransientError: vi.fn(), +})); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + import { queryWithRetry } from "../services/db.js"; +import { + createFreeKey, + updateKeyEmail, + updateEmailByCustomer, + loadKeys, + getAllKeys +} from "../services/keys.js"; -describe("keys service — uncovered branches", () => { - let keys: typeof import("../services/keys.js"); +const mockQuery = vi.mocked(queryWithRetry); - beforeEach(async () => { +describe("keys.ts cache-hit coverage", () => { + beforeEach(() => { vi.clearAllMocks(); - vi.resetModules(); - keys = await import("../services/keys.js"); + // Reset cache by loading empty state + mockQuery.mockResolvedValue({ rows: [], rowCount: 0 } as any); }); - describe("loadKeys error path", () => { - it("sets keysCache to empty array when query fails (ignoring env seeds)", async () => { - const origKeys = process.env.API_KEYS; - delete process.env.API_KEYS; - - vi.mocked(queryWithRetry).mockRejectedValueOnce(new Error("DB down")); - await keys.loadKeys(); - - // Cache should be empty — no keys valid - expect(keys.isValidKey("anything")).toBe(false); - expect(keys.getAllKeys()).toEqual([]); - - process.env.API_KEYS = origKeys; - }); - }); - - describe("loadKeys with API_KEYS env var", () => { - const origEnv = process.env.API_KEYS; - - afterEach(() => { - if (origEnv !== undefined) { - process.env.API_KEYS = origEnv; - } else { - delete process.env.API_KEYS; - } - }); - - it("seeds keys from API_KEYS env var into cache and upserts to DB", async () => { - process.env.API_KEYS = "seed_key_1,seed_key_2"; - - // loadKeys query returns empty (no existing keys) - vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); - // Two upsert calls for seed keys - vi.mocked(queryWithRetry).mockResolvedValue({ rows: [], rowCount: 0 } as any); - - await keys.loadKeys(); - - expect(keys.isValidKey("seed_key_1")).toBe(true); - expect(keys.isValidKey("seed_key_2")).toBe(true); - expect(keys.getKeyInfo("seed_key_1")?.tier).toBe("pro"); - expect(keys.getKeyInfo("seed_key_1")?.email).toBe("seed@docfast.dev"); - - // 1 SELECT + 2 upserts - expect(vi.mocked(queryWithRetry)).toHaveBeenCalledTimes(3); - }); - - it("does not duplicate seed keys already in cache", async () => { - process.env.API_KEYS = "existing_key"; - - // loadKeys returns the key already in DB - vi.mocked(queryWithRetry).mockResolvedValueOnce({ - rows: [{ key: "existing_key", tier: "pro", email: "x@y.com", created_at: "2025-01-01T00:00:00Z", stripe_customer_id: null }], - rowCount: 1, - } as any); - - await keys.loadKeys(); - - // Should not upsert — key already exists - expect(vi.mocked(queryWithRetry)).toHaveBeenCalledTimes(1); - expect(keys.getAllKeys()).toHaveLength(1); - }); - }); - - describe("createFreeKey — email already exists in cache", () => { - it("returns existing free key for same email without DB insert", async () => { - // Load empty cache - vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); - await keys.loadKeys(); - - // First create - vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); - const first = await keys.createFreeKey("dup@test.com"); - - // Second call should return cached key without hitting DB - vi.mocked(queryWithRetry).mockClear(); - const second = await keys.createFreeKey("dup@test.com"); - - expect(second.key).toBe(first.key); - expect(vi.mocked(queryWithRetry)).not.toHaveBeenCalled(); - }); - }); - - describe("createProKey — customer already in cache", () => { - it("updates tier to pro and returns existing entry", async () => { - // Load cache with a free key that has a stripe customer - vi.mocked(queryWithRetry).mockResolvedValueOnce({ - rows: [{ - key: "df_free_old", + it("createFreeKey returns existing key when email has a free key in cache", async () => { + // Pre-populate cache with a free key + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_free_existing123", tier: "free", - email: "user@test.com", - created_at: "2025-01-01T00:00:00Z", - stripe_customer_id: "cus_existing", - }], - rowCount: 1, - } as any); - await keys.loadKeys(); - - // Clear mock call history from loadKeys - vi.mocked(queryWithRetry).mockClear(); - // The UPDATE query - vi.mocked(queryWithRetry).mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); - - const result = await keys.createProKey("user@test.com", "cus_existing"); - - expect(result.key).toBe("df_free_old"); - expect(result.tier).toBe("pro"); - // Should have called UPDATE, not INSERT - const updateCall = vi.mocked(queryWithRetry).mock.calls[0]; - expect(updateCall[0]).toContain("UPDATE"); - }); + email: "existing@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: null, + }, + ], + rowCount: 1, + } as any); + + // Load the cache with our test data + await loadKeys(); + + // Clear mock calls from loadKeys + mockQuery.mockClear(); + + // Now call createFreeKey with the same email - should hit cache and return existing + const result = await createFreeKey("existing@example.com"); + + expect(result.key).toBe("df_free_existing123"); + expect(result.tier).toBe("free"); + expect(result.email).toBe("existing@example.com"); + + // Should NOT have called the database INSERT (cache hit path) + const insertCalls = mockQuery.mock.calls.filter((call) => + (call[0] as string).includes("INSERT") + ); + expect(insertCalls).toHaveLength(0); }); -}); + + it("updateKeyEmail updates cache and DB when key is found in cache", async () => { + // Pre-populate cache with a key + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_pro_test123", + tier: "pro", + email: "old@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: "cus_test123", + }, + ], + rowCount: 1, + } as any); + + // Load the cache + await loadKeys(); + + // Clear mock calls + mockQuery.mockClear(); + + // Mock the UPDATE query + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + + // Call updateKeyEmail - should hit cache + const result = await updateKeyEmail("df_pro_test123", "new@example.com"); + + expect(result).toBe(true); + + // Should have called the UPDATE query + expect(mockQuery).toHaveBeenCalledWith( + "UPDATE api_keys SET email = $1 WHERE key = $2", + ["new@example.com", "df_pro_test123"] + ); + + // Verify cache was updated + const keys = getAllKeys(); + const updatedKey = keys.find(k => k.key === "df_pro_test123"); + expect(updatedKey?.email).toBe("new@example.com"); + }); + + it("updateEmailByCustomer updates cache and DB when stripeCustomerId is found in cache", async () => { + // Pre-populate cache with a key that has stripeCustomerId + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_pro_customer123", + tier: "pro", + email: "customer@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: "cus_customer123", + }, + ], + rowCount: 1, + } as any); + + // Load the cache + await loadKeys(); + + // Clear mock calls + mockQuery.mockClear(); + + // Mock the UPDATE query + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + + // Call updateEmailByCustomer - should hit cache + const result = await updateEmailByCustomer("cus_customer123", "newemail@example.com"); + + expect(result).toBe(true); + + // Should have called the UPDATE query + expect(mockQuery).toHaveBeenCalledWith( + "UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", + ["newemail@example.com", "cus_customer123"] + ); + + // Verify cache was updated + const keys = getAllKeys(); + const updatedKey = keys.find(k => k.stripeCustomerId === "cus_customer123"); + expect(updatedKey?.email).toBe("newemail@example.com"); + }); +}); \ No newline at end of file