diff --git a/src/__tests__/billing-branch-coverage.test.ts b/src/__tests__/billing-branch-coverage.test.ts new file mode 100644 index 0000000..ad2cac9 --- /dev/null +++ b/src/__tests__/billing-branch-coverage.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Mock Stripe before importing billing router +vi.mock("stripe", () => { + const mockStripe = { + checkout: { + sessions: { + create: vi.fn(), + retrieve: vi.fn(), + }, + }, + webhooks: { + constructEvent: vi.fn(), + }, + products: { + search: vi.fn(), + create: vi.fn(), + }, + prices: { + list: vi.fn(), + create: vi.fn(), + }, + subscriptions: { + retrieve: vi.fn(), + }, + }; + return { default: vi.fn(function() { return mockStripe; }), __mockStripe: mockStripe }; +}); + +let app: express.Express; +let mockStripe: any; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + process.env.STRIPE_SECRET_KEY = "sk_test_fake"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake"; + + const stripeMod = await import("stripe"); + mockStripe = (stripeMod as any).__mockStripe; + + // Default: product search returns existing product+price + mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_TygeG8tQPtEAdE" }] }); + mockStripe.prices.list.mockResolvedValue({ data: [{ id: "price_123" }] }); + + const { createProKey, findKeyByCustomerId, downgradeByCustomer, updateEmailByCustomer } = await import("../services/keys.js"); + vi.mocked(createProKey).mockResolvedValue({ key: "pro-key-123", tier: "pro", email: "test@test.com", createdAt: new Date().toISOString() } as any); + vi.mocked(findKeyByCustomerId).mockResolvedValue(null); + vi.mocked(downgradeByCustomer).mockResolvedValue(undefined as any); + vi.mocked(updateEmailByCustomer).mockResolvedValue(true as any); + + const { billingRouter } = await import("../routes/billing.js"); + app = express(); + app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); + app.use(express.json()); + app.use("/v1/billing", billingRouter); +}); + +describe("Billing Branch Coverage", () => { + describe("isDocFastSubscription - expanded product object (lines 63-67)", () => { + it("should handle expanded product object instead of string", async () => { + // Test the branch where price.product is an expanded Stripe.Product object + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_expanded_product", + customer: "cus_expanded", + customer_details: { email: "expanded@test.com" }, + }, + }, + }); + + // Mock: line_items has price.product as an object (not a string) + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_expanded_product", + line_items: { + data: [ + { + price: { + product: { id: "prod_TygeG8tQPtEAdE" } // Expanded object, not string + } + } + ], + }, + }); + + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + + expect(res.status).toBe(200); + expect(createProKey).toHaveBeenCalledWith("expanded@test.com", "cus_expanded"); + }); + + it("should handle expanded product object in subscription.deleted webhook", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_expanded", customer: "cus_expanded_del" }, + }, + }); + + // subscription.retrieve returns expanded product object + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { + data: [ + { + price: { + product: { id: "prod_TygeG8tQPtEAdE" } // Expanded object + } + } + ] + }, + }); + + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_expanded_del"); + }); + }); + + describe("isDocFastSubscription - error handling (lines 70-71)", () => { + it("should return false when subscriptions.retrieve throws an error", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_error", customer: "cus_error" }, + }, + }); + + // Mock: subscriptions.retrieve throws an error + mockStripe.subscriptions.retrieve.mockRejectedValue(new Error("Stripe API error")); + + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + + // Should succeed but NOT downgrade (because isDocFastSubscription returns false on error) + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("should return false when subscriptions.retrieve throws in subscription.updated", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { + id: "sub_update_error", + customer: "cus_update_error", + status: "canceled", + cancel_at_period_end: false, + }, + }, + }); + + mockStripe.subscriptions.retrieve.mockRejectedValue(new Error("Network timeout")); + + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + }); + + describe("getOrCreateProPrice - no existing product (lines 316-331)", () => { + it("should create new product and price when none exists", async () => { + // Mock: no existing product found + mockStripe.products.search.mockResolvedValue({ data: [] }); + + // Mock: product.create returns new product + mockStripe.products.create.mockResolvedValue({ id: "prod_new_123" }); + + // Mock: price.create returns new price + mockStripe.prices.create.mockResolvedValue({ id: "price_new_456" }); + + // Mock: checkout.sessions.create succeeds + mockStripe.checkout.sessions.create.mockResolvedValue({ + id: "cs_new", + url: "https://checkout.stripe.com/pay/cs_new" + }); + + const res = await request(app) + .post("/v1/billing/checkout") + .send({}); + + expect(res.status).toBe(200); + expect(mockStripe.products.create).toHaveBeenCalledWith({ + name: "DocFast Pro", + description: "5,000 PDFs / month via API. HTML, Markdown, and URL to PDF.", + }); + expect(mockStripe.prices.create).toHaveBeenCalledWith({ + product: "prod_new_123", + unit_amount: 900, + currency: "eur", + recurring: { interval: "month" }, + }); + expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + line_items: [{ price: "price_new_456", quantity: 1 }], + }) + ); + }); + + it("should create new price when product exists but has no active prices", async () => { + // Mock: product exists + mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_existing" }] }); + + // Mock: no active prices found + mockStripe.prices.list.mockResolvedValue({ data: [] }); + + // Mock: price.create returns new price + mockStripe.prices.create.mockResolvedValue({ id: "price_new_789" }); + + // Mock: checkout.sessions.create succeeds + mockStripe.checkout.sessions.create.mockResolvedValue({ + id: "cs_existing", + url: "https://checkout.stripe.com/pay/cs_existing" + }); + + const res = await request(app) + .post("/v1/billing/checkout") + .send({}); + + expect(res.status).toBe(200); + expect(mockStripe.products.create).not.toHaveBeenCalled(); // Product exists, don't create + expect(mockStripe.prices.create).toHaveBeenCalledWith({ + product: "prod_existing", + unit_amount: 900, + currency: "eur", + recurring: { interval: "month" }, + }); + expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + line_items: [{ price: "price_new_789", quantity: 1 }], + }) + ); + }); + }); +}); diff --git a/src/__tests__/keys-branch-coverage.test.ts b/src/__tests__/keys-branch-coverage.test.ts new file mode 100644 index 0000000..14cfa54 --- /dev/null +++ b/src/__tests__/keys-branch-coverage.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Unmock keys service — test the real implementation +vi.unmock("../services/keys.js"); + +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 { createProKey, downgradeByCustomer, updateKeyEmail, updateEmailByCustomer } from "../services/keys.js"; + +const mockQuery = vi.mocked(queryWithRetry); + +describe("Keys Branch Coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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", + email: "existing@test.com", + created_at: "2026-01-01T00:00:00.000Z", + 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.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") + ); + expect(upsertCall).toBeTruthy(); + }); + + 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 = { + key: "df_pro_conflict_xyz", + tier: "pro", + email: "conflict@test.com", + created_at: "2025-12-31T00:00:00.000Z", + 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 + }); + }); + + 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") + ); + expect(updateCalls).toHaveLength(0); + }); + + 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"); + + expect(result).toBe(false); + }); + }); + + 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") + ); + expect(updateCalls).toHaveLength(0); + }); + + it("should update and cache when key exists in DB but not in cache", async () => { + const dbKey = { + key: "df_pro_db_only", + tier: "pro", + email: "old@test.com", + created_at: "2026-01-15T00:00:00.000Z", + 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 + + 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"]) + ); + }); + }); + + 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") + ); + expect(updateCalls).toHaveLength(0); + }); + + it("should update and cache when customer exists in DB but not in cache", async () => { + const dbKey = { + key: "df_pro_customer_db", + tier: "pro", + email: "oldcustomer@test.com", + created_at: "2026-02-01T00:00:00.000Z", + 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 + + 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"]) + ); + }); + }); +});