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 }], }) ); }); }); describe("Success route - customerId branch (line 163)", () => { it("should return 400 when session.customer is null (not a string)", async () => { mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_null_customer", customer: null, // Explicitly null, not falsy string customer_details: { email: "test@example.com" }, }); const res = await request(app).get("/v1/billing/success?session_id=cs_null_customer"); expect(res.status).toBe(400); expect(res.body.error).toContain("No customer found"); }); it("should return 400 when customer is empty string", async () => { mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_empty_customer", customer: "", // Empty string is falsy customer_details: { email: "test@example.com" }, }); const res = await request(app).get("/v1/billing/success?session_id=cs_empty_customer"); expect(res.status).toBe(400); expect(res.body.error).toContain("No customer found"); }); it("should return 400 when customer is undefined", async () => { mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_undef_customer", customer: undefined, customer_details: { email: "test@example.com" }, }); const res = await request(app).get("/v1/billing/success?session_id=cs_undef_customer"); expect(res.status).toBe(400); expect(res.body.error).toContain("No customer found"); }); }); describe("Webhook checkout.session.completed - hasDocfastProduct branch (line 223)", () => { it("should skip webhook event when line_items is undefined", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: { id: "cs_no_items", customer: "cus_no_items", customer_details: { email: "test@example.com" }, }, }, }); // Mock: line_items is undefined mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_no_items", line_items: undefined, }); 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).not.toHaveBeenCalled(); }); it("should skip webhook event when line_items.data is empty", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: { id: "cs_empty_items", customer: "cus_empty_items", customer_details: { email: "test@example.com" }, }, }, }); mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_empty_items", line_items: { data: [] }, // Empty array - no items }); 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).not.toHaveBeenCalled(); }); it("should skip webhook event when price is null", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: { id: "cs_null_price", customer: "cus_null_price", customer_details: { email: "test@example.com" }, }, }, }); mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_null_price", line_items: { data: [{ price: null }], // Null price }, }); 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).not.toHaveBeenCalled(); }); it("should skip webhook event when product is null", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", data: { object: { id: "cs_null_product", customer: "cus_null_product", customer_details: { email: "test@example.com" }, }, }, }); mockStripe.checkout.sessions.retrieve.mockResolvedValue({ id: "cs_null_product", line_items: { data: [{ price: { product: null } }], // Null product }, }); 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).not.toHaveBeenCalled(); }); }); describe("Webhook customer.updated event (line 284-303)", () => { it("should sync email when both customerId and newEmail exist", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", data: { object: { id: "cus_email_update", email: "newemail@example.com", }, }, }); const { updateEmailByCustomer } = await import("../services/keys.js"); vi.mocked(updateEmailByCustomer).mockResolvedValue(true); const res = await request(app) .post("/v1/billing/webhook") .set("content-type", "application/json") .set("stripe-signature", "valid_sig") .send(JSON.stringify({ type: "customer.updated" })); expect(res.status).toBe(200); expect(updateEmailByCustomer).toHaveBeenCalledWith("cus_email_update", "newemail@example.com"); }); it("should not sync email when customerId is missing", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", data: { object: { id: undefined, // Missing customerId email: "newemail@example.com", }, }, }); const { updateEmailByCustomer } = 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.updated" })); expect(res.status).toBe(200); expect(updateEmailByCustomer).not.toHaveBeenCalled(); }); it("should not sync email when email is missing", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", data: { object: { id: "cus_no_email", email: null, // Missing email }, }, }); const { updateEmailByCustomer } = 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.updated" })); expect(res.status).toBe(200); expect(updateEmailByCustomer).not.toHaveBeenCalled(); }); it("should not sync email when email is undefined", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", data: { object: { id: "cus_no_email_2", email: undefined, // Undefined email }, }, }); const { updateEmailByCustomer } = 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.updated" })); expect(res.status).toBe(200); expect(updateEmailByCustomer).not.toHaveBeenCalled(); }); it("should log when email update returns false", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", data: { object: { id: "cus_no_update", email: "newemail@example.com", }, }, }); const { updateEmailByCustomer } = await import("../services/keys.js"); vi.mocked(updateEmailByCustomer).mockResolvedValue(false); // Update returns false const res = await request(app) .post("/v1/billing/webhook") .set("content-type", "application/json") .set("stripe-signature", "valid_sig") .send(JSON.stringify({ type: "customer.updated" })); expect(res.status).toBe(200); expect(updateEmailByCustomer).toHaveBeenCalledWith("cus_no_update", "newemail@example.com"); // The if (updated) branch should not be executed when false }); }); describe("Webhook default case", () => { it("should handle unknown webhook event type", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "unknown.event.type", data: { object: {} }, }); const res = await request(app) .post("/v1/billing/webhook") .set("content-type", "application/json") .set("stripe-signature", "valid_sig") .send(JSON.stringify({ type: "unknown.event.type" })); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it("should handle payment_intent.succeeded webhook event", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "payment_intent.succeeded", data: { object: {} }, }); const res = await request(app) .post("/v1/billing/webhook") .set("content-type", "application/json") .set("stripe-signature", "valid_sig") .send(JSON.stringify({ type: "payment_intent.succeeded" })); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); it("should handle invoice.payment_succeeded webhook event", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "invoice.payment_succeeded", data: { object: {} }, }); const res = await request(app) .post("/v1/billing/webhook") .set("content-type", "application/json") .set("stripe-signature", "valid_sig") .send(JSON.stringify({ type: "invoice.payment_succeeded" })); expect(res.status).toBe(200); expect(res.body.received).toBe(true); }); }); });