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.
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
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 }],
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|