Added edge case tests for: - Line 231-233: checkout.session.completed webhook catch block when session.retrieve fails - Line 165: /success route !customerId check with various falsy values - Line 315: getOrCreateProPrice() else branch when no product exists All 827 tests pass.
153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
|
|
// Mock Stripe ONCE before any imports
|
|
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(),
|
|
},
|
|
};
|
|
|
|
vi.mock("stripe", () => {
|
|
return { default: vi.fn(function() { return mockStripe; }) };
|
|
});
|
|
|
|
// Mock keys service
|
|
vi.mock("../services/keys.js", () => ({
|
|
createProKey: vi.fn().mockResolvedValue({
|
|
key: "pro-key-123",
|
|
tier: "pro",
|
|
email: "test@test.com",
|
|
createdAt: new Date().toISOString()
|
|
}),
|
|
findKeyByCustomerId: vi.fn().mockResolvedValue(null),
|
|
downgradeByCustomer: vi.fn().mockResolvedValue(undefined),
|
|
updateEmailByCustomer: vi.fn().mockResolvedValue(true),
|
|
}));
|
|
|
|
// Set env BEFORE importing billing router (NO module reset!)
|
|
process.env.STRIPE_SECRET_KEY = "sk_test_fake_no_reset";
|
|
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake_no_reset";
|
|
|
|
// Import billing router ONCE
|
|
import { billingRouter } from "../routes/billing.js";
|
|
import { createProKey, findKeyByCustomerId } from "../services/keys.js";
|
|
|
|
let app: express.Express;
|
|
|
|
beforeAll(() => {
|
|
// Setup Express app once
|
|
app = express();
|
|
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
|
app.use(express.json());
|
|
app.use("/v1/billing", billingRouter);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Only clear mock calls, DON'T reset modules
|
|
vi.clearAllMocks();
|
|
|
|
// Reset default mock implementations
|
|
mockStripe.products.search.mockResolvedValue({ data: [{ id: "prod_TygeG8tQPtEAdE" }] });
|
|
mockStripe.prices.list.mockResolvedValue({ data: [{ id: "price_existing_123" }] });
|
|
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);
|
|
});
|
|
|
|
describe("Billing Coverage Fix - NO MODULE RESET", () => {
|
|
describe("Line 231-233: checkout.session.completed webhook catch block", () => {
|
|
it("should gracefully handle Stripe API error when retrieving session", async () => {
|
|
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
type: "checkout.session.completed",
|
|
data: {
|
|
object: {
|
|
id: "cs_api_error",
|
|
customer: "cus_api_error",
|
|
customer_details: { email: "error@test.com" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Trigger the catch block: session.retrieve throws
|
|
mockStripe.checkout.sessions.retrieve.mockRejectedValueOnce(new Error("Stripe API is down"));
|
|
|
|
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(res.body.received).toBe(true);
|
|
// Key should NOT be provisioned due to error
|
|
expect(createProKey).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Line 165: /success route !customerId branch", () => {
|
|
it("should return 400 when customer is null", async () => {
|
|
mockStripe.checkout.sessions.retrieve.mockResolvedValueOnce({
|
|
id: "cs_null_cust",
|
|
customer: null,
|
|
customer_details: { email: "test@test.com" },
|
|
});
|
|
|
|
const res = await request(app).get("/v1/billing/success?session_id=cs_null_cust");
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No customer found");
|
|
});
|
|
});
|
|
|
|
describe("Line 315: getOrCreateProPrice() else branch - create new product", () => {
|
|
it("should create new product and price when none exist", async () => {
|
|
// Clear cache by calling checkout with empty product list
|
|
mockStripe.products.search.mockResolvedValueOnce({ data: [] });
|
|
mockStripe.products.create.mockResolvedValueOnce({ id: "prod_new_123" });
|
|
mockStripe.prices.create.mockResolvedValueOnce({ id: "price_new_456" });
|
|
mockStripe.checkout.sessions.create.mockResolvedValueOnce({
|
|
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" },
|
|
});
|
|
});
|
|
});
|
|
});
|