diff --git a/src/__tests__/billing-branch-coverage.test.ts b/src/__tests__/billing-branch-coverage.test.ts index ad2cac9..ba951c0 100644 --- a/src/__tests__/billing-branch-coverage.test.ts +++ b/src/__tests__/billing-branch-coverage.test.ts @@ -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); + }); + }); }); diff --git a/src/__tests__/demo-branch-coverage.test.ts b/src/__tests__/demo-branch-coverage.test.ts new file mode 100644 index 0000000..05befe4 --- /dev/null +++ b/src/__tests__/demo-branch-coverage.test.ts @@ -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 tag", async () => { + const { renderPdf } = await import("../services/browser.js"); + // Send full HTML (with ) but without to hit the fallback branch + const htmlWithoutClosingBody = ` + + Test Page + +

Hello

+

Content here

+ `; + + 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 to replace) + const lastBodyCloseIndex = calledHtml.lastIndexOf(""); + const watermarkIndex = calledHtml.indexOf("Generated by DocFast"); + // If there's a 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 ", async () => { + const { renderPdf } = await import("../services/browser.js"); + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "
Simple fragment
" }); + + expect(res.status).toBe(200); + + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toContain("
Simple fragment
"); + expect(calledHtml).toContain("DEMO"); + expect(calledHtml).toContain("position:fixed;bottom:0;left:0;right:0;"); + }); + + it("should handle markdown that results in HTML without 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 (replace branch)", async () => { + const { renderPdf } = await import("../services/browser.js"); + const fullHtml = ` + + Test + +

Complete HTML

+

With closing body tag

+ + + `; + + 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 exists, watermark should be injected before it + expect(calledHtml).toContain(""); + expect(calledHtml).toContain("DEMO"); + + // The watermark should be between the content and closing + const watermarkIndex = calledHtml.indexOf("Generated by DocFast"); + const closingBodyIndex = calledHtml.indexOf(""); + 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 tags (uses first)", async () => { + const { renderPdf } = await import("../services/browser.js"); + const htmlWithMultipleBodies = ` + + First body + Second body + + `; + + 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(""); + }); + }); + + 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: "

Test

" }); + + 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: "

Test

", 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: "

Hello

" }); + + 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"/); + }); + }); +});