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:
OpenClaw Subagent 2026-03-19 20:12:28 +01:00
parent 6d1d8f405f
commit e6638fb929
3 changed files with 329 additions and 0 deletions

File diff suppressed because one or more lines are too long

View 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" },
});
});
});
});

View 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 }],
})
);
});
});
});