test: improve billing.ts and demo.ts branch coverage
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 21m45s

This commit is contained in:
OpenClaw Subagent 2026-03-14 17:13:36 +01:00
parent 3aae96fd8a
commit 1363c61e39
2 changed files with 553 additions and 0 deletions

View file

@ -257,4 +257,334 @@ describe("Billing Branch Coverage", () => {
); );
}); });
}); });
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);
});
});
}); });

View file

@ -0,0 +1,223 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
vi.mock("../services/browser.js", () => ({
renderPdf: vi.fn(),
renderUrlPdf: vi.fn(),
}));
let app: express.Express;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
const { renderPdf } = await import("../services/browser.js");
vi.mocked(renderPdf).mockResolvedValue({ pdf: Buffer.from("%PDF-1.4 mock"), durationMs: 10 });
const { demoRouter } = await import("../routes/demo.js");
app = express();
app.use(express.json({ limit: "500kb" }));
app.use("/v1/demo", demoRouter);
});
describe("Demo Branch Coverage", () => {
describe("injectWatermark fallback branch (line 19)", () => {
it("should append watermark when full HTML document doesn't contain </body> tag", async () => {
const { renderPdf } = await import("../services/browser.js");
// Send full HTML (with <html>) but without </body> to hit the fallback branch
const htmlWithoutClosingBody = `
<html>
<head><title>Test Page</title></head>
<body>
<h1>Hello</h1>
<p>Content here</p>
`;
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: htmlWithoutClosingBody });
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/application\/pdf/);
// Verify watermark was appended (not replaced)
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// The fallback should append the watermark at the end
expect(calledHtml).toContain("Hello");
expect(calledHtml).toContain("Content here");
expect(calledHtml).toContain("DEMO");
expect(calledHtml).toContain("Generated by DocFast");
// Ensure the original HTML is preserved before the watermark
expect(calledHtml.indexOf("Hello")).toBeLessThan(calledHtml.indexOf("DEMO"));
// Ensure watermark is appended at the end (since there's no </body> to replace)
const lastBodyCloseIndex = calledHtml.lastIndexOf("</body>");
const watermarkIndex = calledHtml.indexOf("Generated by DocFast");
// If there's a </body> at the very end (from wrapping), the watermark should be before it
if (lastBodyCloseIndex > -1) {
expect(watermarkIndex).toBeLessThan(lastBodyCloseIndex);
}
});
it("should append watermark to plain HTML fragment without </body>", async () => {
const { renderPdf } = await import("../services/browser.js");
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: "<div>Simple fragment</div>" });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
expect(calledHtml).toContain("<div>Simple fragment</div>");
expect(calledHtml).toContain("DEMO");
expect(calledHtml).toContain("position:fixed;bottom:0;left:0;right:0;");
});
it("should handle markdown that results in HTML without </body> and injects watermark", async () => {
const { renderPdf } = await import("../services/browser.js");
const res = await request(app)
.post("/v1/demo/markdown")
.set("content-type", "application/json")
.send({ markdown: "# Just a heading\n\nSome text" });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// Should contain watermark
expect(calledHtml).toContain("DEMO");
expect(calledHtml).toContain("Generated by DocFast");
expect(calledHtml).toContain("Upgrade to Pro for clean PDFs");
});
it("should still work correctly when HTML contains </body> (replace branch)", async () => {
const { renderPdf } = await import("../services/browser.js");
const fullHtml = `
<html>
<head><title>Test</title></head>
<body>
<h1>Complete HTML</h1>
<p>With closing body tag</p>
</body>
</html>
`;
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: fullHtml });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// When </body> exists, watermark should be injected before it
expect(calledHtml).toContain("</body>");
expect(calledHtml).toContain("DEMO");
// The watermark should be between the content and closing </body>
const watermarkIndex = calledHtml.indexOf("Generated by DocFast");
const closingBodyIndex = calledHtml.indexOf("</body>");
expect(watermarkIndex).toBeGreaterThan(-1);
expect(closingBodyIndex).toBeGreaterThan(-1);
expect(watermarkIndex).toBeLessThan(closingBodyIndex);
});
it("should reject empty HTML input with 400 error", async () => {
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: "" });
// Empty HTML is rejected by validation
expect(res.status).toBe(400);
expect(res.body.error).toContain("html");
});
it("should handle HTML with multiple </body> tags (uses first)</body>", async () => {
const { renderPdf } = await import("../services/browser.js");
const htmlWithMultipleBodies = `
<html>
<body>First body</body>
<body>Second body</body>
</html>
`;
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: htmlWithMultipleBodies });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// replace only replaces the first occurrence
expect(calledHtml).toContain("First body");
expect(calledHtml).toContain("DEMO");
expect(calledHtml).toContain("</body>");
});
});
describe("Watermark content verification", () => {
it("should include demo watermark with exact styling", async () => {
const { renderPdf } = await import("../services/browser.js");
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: "<h1>Test</h1>" });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// Verify watermark styling
expect(calledHtml).toContain("background:rgba(52,211,153,0.92);color:#0b0d11");
expect(calledHtml).toContain("z-index:999999");
expect(calledHtml).toContain("pointer-events:none");
});
it("should preserve user CSS when injecting watermark", async () => {
const { renderPdf } = await import("../services/browser.js");
const customCss = "body { background: blue; }";
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: "<h1>Test</h1>", css: customCss });
expect(res.status).toBe(200);
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
// Both watermark and user CSS should be present
expect(calledHtml).toContain("DEMO");
expect(calledHtml).toContain("background: blue");
});
});
describe("Branch coverage for attachment headers", () => {
it("should set Content-Disposition to attachment for HTML", async () => {
const res = await request(app)
.post("/v1/demo/html")
.set("content-type", "application/json")
.send({ html: "<h1>Hello</h1>" });
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toMatch(/^attachment/);
expect(res.headers["content-disposition"]).toMatch(/filename="demo\.pdf"/);
});
it("should set Content-Disposition to attachment for markdown", async () => {
const res = await request(app)
.post("/v1/demo/markdown")
.set("content-type", "application/json")
.send({ markdown: "# Hello" });
expect(res.status).toBe(200);
expect(res.headers["content-disposition"]).toMatch(/^attachment/);
expect(res.headers["content-disposition"]).toMatch(/filename="demo\.pdf"/);
});
});
});