import { describe, it, expect, vi, beforeEach, afterEach } 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); }); afterEach(() => { vi.restoreAllMocks(); }); describe("Billing Edge Cases - Branch Coverage Improvements", () => { describe("Line 231-233: checkout.session.completed webhook - catch block when session.retrieve fails", () => { it("RED: should handle error when retrieving session line_items throws", async () => { // Setup webhook event mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: { id: "cs_retrieve_error", customer: "cus_retrieve_error", customer_details: { email: "error@test.com" }, }, }, }); // Mock: session.retrieve throws an error (network timeout, Stripe API error, etc.) mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe API timeout")); const { createProKey } = await import("../services/keys.js"); // Send webhook request 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" })); // Should return 200 but not provision key (graceful degradation) expect(res.status).toBe(200); expect(res.body.received).toBe(true); expect(createProKey).not.toHaveBeenCalled(); }); }); describe("Line 165: /success route - !customerId check", () => { it("RED: should return 400 when customerId is missing", async () => { // Mock: session has no customer mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_no_customer", customer: null, customer_details: { email: "test@test.com" }, }); const res = await request(app).get("/v1/billing/success?session_id=cs_no_customer"); expect(res.status).toBe(400); expect(res.body.error).toMatch(/No customer found/); }); it("RED: should return 400 when customerId is empty string", async () => { mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_empty_customer", customer: "", customer_details: { email: "test@test.com" }, }); const res = await request(app).get("/v1/billing/success?session_id=cs_empty_customer"); expect(res.status).toBe(400); expect(res.body.error).toMatch(/No customer found/); }); }); describe("Line 315: getOrCreateProPrice() - else branch (no existing product)", () => { it("RED: should create new product when products.search returns empty", async () => { // Mock: no existing product found mockStripe.products.search.mockResolvedValue({ data: [] }); // Mock: product.create returns new product mockStripe.products.create.mockResolvedValue({ id: "prod_new_created" }); // Mock: price.create returns new price mockStripe.prices.create.mockResolvedValue({ id: "price_new_created" }); // Mock: checkout.sessions.create succeeds mockStripe.checkout.sessions.create.mockResolvedValue({ id: "cs_new_product", url: "https://checkout.stripe.com/pay/cs_new" }); const res = await request(app) .post("/v1/billing/checkout") .send({}); expect(res.status).toBe(200); // Verify product was created expect(mockStripe.products.create).toHaveBeenCalledWith({ name: "DocFast Pro", description: "5,000 PDFs / month via API. HTML, Markdown, and URL to PDF.", }); // Verify price was created with the new product ID expect(mockStripe.prices.create).toHaveBeenCalledWith({ product: "prod_new_created", unit_amount: 900, currency: "eur", recurring: { interval: "month" }, }); // Verify checkout session was created with the new price expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith( expect.objectContaining({ line_items: [{ price: "price_new_created", quantity: 1 }], }) ); }); }); });