test: improve billing webhook branch coverage
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.
This commit is contained in:
parent
6d1d8f405f
commit
e6638fb929
3 changed files with 329 additions and 0 deletions
2
coverage/coverage-final.json
Normal file
2
coverage/coverage-final.json
Normal file
File diff suppressed because one or more lines are too long
153
src/__tests__/billing-coverage-fix.test.ts
Normal file
153
src/__tests__/billing-coverage-fix.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
174
src/__tests__/billing-edge-cases.test.ts
Normal file
174
src/__tests__/billing-edge-cases.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
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 }],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue