diff --git a/package-lock.json b/package-lock.json index 4cfdcb7..28ec627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.5.0", "@types/nodemailer": "^7.0.11", - "@types/pg": "^8.18.0", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", "@vitest/coverage-v8": "^4.1.0", @@ -1220,9 +1220,9 @@ } }, "node_modules/@types/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 80a56de..5a3d6b3 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.5.0", "@types/nodemailer": "^7.0.11", - "@types/pg": "^8.18.0", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", "@types/swagger-jsdoc": "^6.0.4", "@vitest/coverage-v8": "^4.1.0", diff --git a/src/__tests__/keys-branch-coverage.test.ts b/src/__tests__/keys-branch-coverage.test.ts index 14cfa54..9b61773 100644 --- a/src/__tests__/keys-branch-coverage.test.ts +++ b/src/__tests__/keys-branch-coverage.test.ts @@ -18,7 +18,16 @@ vi.mock("../services/logger.js", () => ({ })); import { queryWithRetry } from "../services/db.js"; -import { createProKey, downgradeByCustomer, updateKeyEmail, updateEmailByCustomer } from "../services/keys.js"; +import { + loadKeys, + getAllKeys, + createFreeKey, + createProKey, + findKeyInCacheOrDb, + downgradeByCustomer, + updateKeyEmail, + updateEmailByCustomer, +} from "../services/keys.js"; const mockQuery = vi.mocked(queryWithRetry); @@ -27,11 +36,138 @@ describe("Keys Branch Coverage", () => { vi.clearAllMocks(); }); + // === NEW: Date conversion and cache branches === + + describe("loadKeys — Date branch for created_at (line 46)", () => { + it("converts Date objects to ISO strings in loadKeys", async () => { + const dateObj = new Date("2026-01-15T10:00:00.000Z"); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_free_abc", + tier: "free", + email: "test@example.com", + created_at: dateObj, + stripe_customer_id: null, + }, + ], + rowCount: 1, + } as any); + + delete process.env.API_KEYS; + await loadKeys(); + + const keys = getAllKeys(); + const loaded = keys.find((k) => k.key === "df_free_abc"); + expect(loaded).toBeDefined(); + expect(loaded!.createdAt).toBe("2026-01-15T10:00:00.000Z"); + }); + }); + + describe("findKeyInCacheOrDb — Date branch for created_at", () => { + it("converts Date objects to ISO strings in findKeyInCacheOrDb", async () => { + const dateObj = new Date("2026-02-20T12:00:00.000Z"); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_pro_xyz", + tier: "pro", + email: "pro@example.com", + created_at: dateObj, + stripe_customer_id: "cus_abc", + }, + ], + rowCount: 1, + } as any); + + const result = await findKeyInCacheOrDb("key", "df_pro_xyz"); + expect(result).toBeDefined(); + expect(result!.createdAt).toBe("2026-02-20T12:00:00.000Z"); + }); + }); + + describe("createFreeKey — existing email in cache (line 88)", () => { + it("returns existing free key when email already in cache", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_free_existing", + tier: "free", + email: "cached@example.com", + created_at: "2026-01-01T00:00:00.000Z", + stripe_customer_id: null, + }, + ], + rowCount: 1, + } as any); + delete process.env.API_KEYS; + await loadKeys(); + + const result = await createFreeKey("cached@example.com"); + expect(result.key).toBe("df_free_existing"); + expect(result.email).toBe("cached@example.com"); + expect(mockQuery).toHaveBeenCalledTimes(1); // only the loadKeys call + }); + }); + + describe("createProKey — Date branch in RETURNING row (line 135)", () => { + it("converts Date created_at in createProKey RETURNING row", async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + delete process.env.API_KEYS; + await loadKeys(); + + const dateObj = new Date("2026-03-01T08:00:00.000Z"); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_pro_new123", + tier: "pro", + email: "pro@example.com", + created_at: dateObj, + stripe_customer_id: "cus_new", + }, + ], + rowCount: 1, + } as any); + + const result = await createProKey("pro@example.com", "cus_new"); + expect(result.createdAt).toBe("2026-03-01T08:00:00.000Z"); + expect(result.key).toBe("df_pro_new123"); + }); + }); + + describe("createProKey — cache miss path, push to cache (line 141)", () => { + it("pushes new entry to cache when stripeCustomerId not in cache", async () => { + mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); + delete process.env.API_KEYS; + await loadKeys(); + + mockQuery.mockResolvedValueOnce({ + rows: [ + { + key: "df_pro_pushed", + tier: "pro", + email: "new@example.com", + created_at: "2026-03-15T10:00:00.000Z", + stripe_customer_id: "cus_brand_new", + }, + ], + rowCount: 1, + } as any); + + const result = await createProKey("new@example.com", "cus_brand_new"); + expect(result.key).toBe("df_pro_pushed"); + expect(result.stripeCustomerId).toBe("cus_brand_new"); + + const keys = getAllKeys(); + expect(keys.some((k) => k.key === "df_pro_pushed")).toBe(true); + }); + }); + + // === ORIGINAL: UPSERT conflict, downgrade, updateKeyEmail, updateEmailByCustomer === + describe("createProKey - UPSERT conflict path (line 142)", () => { it("should return existing key when stripe_customer_id already exists in DB but NOT in cache", async () => { - // Scenario: Another pod created a key for this customer, so it's in DB but not in our cache - // The UPSERT will hit ON CONFLICT and return the existing key via RETURNING clause - const existingKey = { key: "df_pro_existing_abc", tier: "pro", @@ -40,18 +176,15 @@ describe("Keys Branch Coverage", () => { stripe_customer_id: "cus_existing", }; - // Mock: UPSERT returns the existing key (ON CONFLICT triggered) mockQuery.mockResolvedValueOnce({ rows: [existingKey], rowCount: 1 } as any); const result = await createProKey("new@test.com", "cus_existing"); - // Should return the existing key expect(result.key).toBe("df_pro_existing_abc"); - expect(result.email).toBe("existing@test.com"); // Original email, not the new one + expect(result.email).toBe("existing@test.com"); expect(result.stripeCustomerId).toBe("cus_existing"); expect(result.tier).toBe("pro"); - // Verify UPSERT was called const upsertCall = mockQuery.mock.calls.find( (c) => typeof c[0] === "string" && c[0].includes("ON CONFLICT") ); @@ -59,12 +192,8 @@ describe("Keys Branch Coverage", () => { }); it("should handle conflict when inserting new key with existing customer ID", async () => { - // First call: load empty cache mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); - - const { loadKeys } = await import("../services/keys.js"); await loadKeys(); - vi.clearAllMocks(); const conflictingKey = { @@ -75,32 +204,27 @@ describe("Keys Branch Coverage", () => { stripe_customer_id: "cus_conflict", }; - // UPSERT returns existing key on conflict mockQuery.mockResolvedValueOnce({ rows: [conflictingKey], rowCount: 1 } as any); const result = await createProKey("different-email@test.com", "cus_conflict"); expect(result.key).toBe("df_pro_conflict_xyz"); - expect(result.email).toBe("conflict@test.com"); // Original, not the new email + expect(result.email).toBe("conflict@test.com"); }); }); describe("downgradeByCustomer - customer not found (lines 153-155)", () => { it("should return false when customer is NOT in cache AND NOT in DB", async () => { - // Mock: SELECT query returns empty (customer not in DB) mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); const result = await downgradeByCustomer("cus_nonexistent"); expect(result).toBe(false); - - // Verify SELECT was called expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("SELECT"), expect.arrayContaining(["cus_nonexistent"]) ); - // Verify UPDATE was NOT called (no point updating a non-existent key) const updateCalls = mockQuery.mock.calls.filter((c) => (c[0] as string).includes("UPDATE") ); @@ -108,14 +232,10 @@ describe("Keys Branch Coverage", () => { }); it("should return false for completely unknown stripe customer ID", async () => { - // Load empty cache first mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); - const { loadKeys } = await import("../services/keys.js"); await loadKeys(); - vi.clearAllMocks(); - // Mock: DB also doesn't have this customer mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); const result = await downgradeByCustomer("cus_unknown_12345"); @@ -126,20 +246,16 @@ describe("Keys Branch Coverage", () => { describe("updateKeyEmail - DB fallback path (line 175)", () => { it("should return false when key is NOT in cache AND NOT in DB", async () => { - // Mock: SELECT query returns empty (key not in DB) mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); const result = await updateKeyEmail("df_pro_nonexistent", "new@test.com"); expect(result).toBe(false); - - // Verify SELECT was called expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("SELECT"), expect.arrayContaining(["df_pro_nonexistent"]) ); - // Verify UPDATE was NOT called const updateCalls = mockQuery.mock.calls.filter((c) => (c[0] as string).includes("UPDATE") ); @@ -155,16 +271,13 @@ describe("Keys Branch Coverage", () => { stripe_customer_id: "cus_db_only", }; - // Mock: SELECT returns the key from DB mockQuery .mockResolvedValueOnce({ rows: [dbKey], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE success + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); const result = await updateKeyEmail("df_pro_db_only", "updated@test.com"); expect(result).toBe(true); - - // Verify UPDATE was called expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("UPDATE"), expect.arrayContaining(["updated@test.com", "df_pro_db_only"]) @@ -174,20 +287,16 @@ describe("Keys Branch Coverage", () => { describe("updateEmailByCustomer - DB fallback path (line 175)", () => { it("should return false when customer is NOT in cache AND NOT in DB", async () => { - // Mock: SELECT query returns empty mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 } as any); const result = await updateEmailByCustomer("cus_nonexistent", "new@test.com"); expect(result).toBe(false); - - // Verify SELECT was called expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("SELECT"), expect.arrayContaining(["cus_nonexistent"]) ); - // Verify UPDATE was NOT called const updateCalls = mockQuery.mock.calls.filter((c) => (c[0] as string).includes("UPDATE") ); @@ -203,16 +312,13 @@ describe("Keys Branch Coverage", () => { stripe_customer_id: "cus_db_customer", }; - // Mock: SELECT returns the key mockQuery .mockResolvedValueOnce({ rows: [dbKey], rowCount: 1 } as any) - .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); // UPDATE success + .mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); const result = await updateEmailByCustomer("cus_db_customer", "newcustomer@test.com"); expect(result).toBe(true); - - // Verify UPDATE was called with correct params expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("UPDATE"), expect.arrayContaining(["newcustomer@test.com", "cus_db_customer"])