From f0e9a79606a7684515e110bf4003d5e9cc2b6e7c Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Thu, 26 Feb 2026 19:03:48 +0000 Subject: [PATCH] test: add billing and convert route tests --- src/__tests__/billing.test.ts | 294 ++++++++++++++++++++++++++++++++++ src/__tests__/convert.test.ts | 184 +++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/__tests__/billing.test.ts create mode 100644 src/__tests__/convert.test.ts diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts new file mode 100644 index 0000000..07790dc --- /dev/null +++ b/src/__tests__/billing.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// We need to 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(() => 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"; + + // Re-import to get fresh mocks + 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(); + // Webhook needs raw body + app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); + app.use(express.json()); + app.use("/v1/billing", billingRouter); +}); + +describe("POST /v1/billing/checkout", () => { + it("returns url on success", async () => { + mockStripe.checkout.sessions.create.mockResolvedValue({ id: "cs_123", url: "https://checkout.stripe.com/pay/cs_123" }); + const res = await request(app).post("/v1/billing/checkout").send({}); + expect(res.status).toBe(200); + expect(res.body.url).toBe("https://checkout.stripe.com/pay/cs_123"); + }); + + it("returns 413 for body too large", async () => { + // The route checks content-length header; send a large body to trigger it + const largeBody = JSON.stringify({ padding: "x".repeat(2000) }); + const res = await request(app) + .post("/v1/billing/checkout") + .set("content-type", "application/json") + .send(largeBody); + expect(res.status).toBe(413); + }); + + it("returns 500 on Stripe error", async () => { + mockStripe.checkout.sessions.create.mockRejectedValue(new Error("Stripe down")); + const res = await request(app).post("/v1/billing/checkout").send({}); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/Failed to create checkout session/); + }); +}); + +describe("GET /v1/billing/success", () => { + it("returns 400 for missing session_id", async () => { + const res = await request(app).get("/v1/billing/success"); + expect(res.status).toBe(400); + }); + + it("returns 409 for duplicate session", async () => { + // First call succeeds + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_dup", + customer: "cus_123", + customer_details: { email: "test@test.com" }, + }); + await request(app).get("/v1/billing/success?session_id=cs_dup"); + // Second call with same session + const res = await request(app).get("/v1/billing/success?session_id=cs_dup"); + expect(res.status).toBe(409); + }); + + it("returns existing key page when key already in DB", async () => { + const { findKeyByCustomerId } = await import("../services/keys.js"); + vi.mocked(findKeyByCustomerId).mockResolvedValue({ key: "existing-key", tier: "pro" } as any); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_existing", + customer: "cus_existing", + customer_details: { email: "test@test.com" }, + }); + const res = await request(app).get("/v1/billing/success?session_id=cs_existing"); + expect(res.status).toBe(200); + expect(res.text).toContain("Key Already Provisioned"); + }); + + it("provisions new key on success", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_new", + customer: "cus_new", + customer_details: { email: "new@test.com" }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app).get("/v1/billing/success?session_id=cs_new"); + expect(res.status).toBe(200); + expect(res.text).toContain("Welcome to Pro"); + expect(createProKey).toHaveBeenCalledWith("new@test.com", "cus_new"); + }); + + it("returns 500 on Stripe error", async () => { + mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe error")); + const res = await request(app).get("/v1/billing/success?session_id=cs_err"); + expect(res.status).toBe(500); + }); +}); + +describe("POST /v1/billing/webhook", () => { + it("returns 500 when webhook secret missing", async () => { + delete process.env.STRIPE_WEBHOOK_SECRET; + // Need to re-import to pick up env change - but the router is already loaded + // The router reads env at request time, so this should work + const savedSecret = process.env.STRIPE_WEBHOOK_SECRET; + process.env.STRIPE_WEBHOOK_SECRET = ""; + delete process.env.STRIPE_WEBHOOK_SECRET; + + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "sig_test") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(500); + + process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_fake"; + }); + + it("returns 400 for missing signature", async () => { + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Missing stripe-signature/); + }); + + it("returns 400 for invalid signature", async () => { + mockStripe.webhooks.constructEvent.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "bad_sig") + .send(JSON.stringify({ type: "test" })); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid signature/); + }); + + it("provisions key on checkout.session.completed for DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_wh", + customer: "cus_wh", + customer_details: { email: "wh@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_wh", + line_items: { + data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }], + }, + }); + 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(res.body.received).toBe(true); + expect(createProKey).toHaveBeenCalledWith("wh@test.com", "cus_wh"); + }); + + it("ignores checkout.session.completed for non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_other", + customer: "cus_other", + customer_details: { email: "other@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_other", + line_items: { + data: [{ price: { product: "prod_OTHER" } }], + }, + }); + 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("downgrades on customer.subscription.deleted", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_del", customer: "cus_del" }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = 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.subscription.deleted" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_del"); + }); + + it("downgrades on customer.subscription.updated with cancel_at_period_end", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_cancel", customer: "cus_cancel", status: "active", cancel_at_period_end: true }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = 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.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel"); + }); + + it("syncs email on customer.updated", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.updated", + data: { + object: { id: "cus_email", email: "newemail@test.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).toHaveBeenCalledWith("cus_email", "newemail@test.com"); + }); +}); diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts new file mode 100644 index 0000000..5cc7c1c --- /dev/null +++ b/src/__tests__/convert.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Mock dns before imports +vi.mock("node:dns/promises", () => ({ + default: { lookup: vi.fn() }, + lookup: vi.fn(), +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { renderPdf, renderUrlPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock")); + vi.mocked(renderUrlPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock url")); + + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockResolvedValue({ address: "93.184.216.34", family: 4 } as any); + + const { convertRouter } = await import("../routes/convert.js"); + app = express(); + app.use(express.json({ limit: "500kb" })); + app.use("/v1/convert", convertRouter); +}); + +describe("POST /v1/convert/html", () => { + it("returns 400 for missing html", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "text/plain") + .send("html=

hi

"); + expect(res.status).toBe(415); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); + + it("returns 429 on QUEUE_FULL", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(429); + }); + + it("returns 500 on PDF_TIMEOUT", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); + const res = await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Hello

" }); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/PDF_TIMEOUT/); + }); + + it("wraps fragments (no { + const { renderPdf } = await import("../services/browser.js"); + await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: "

Fragment

" }); + // wrapHtml should have been called; renderPdf receives wrapped HTML + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toContain(" { + const { renderPdf } = await import("../services/browser.js"); + const fullDoc = "

Full

"; + await request(app) + .post("/v1/convert/html") + .set("content-type", "application/json") + .send({ html: fullDoc }); + const calledHtml = vi.mocked(renderPdf).mock.calls[0][0]; + expect(calledHtml).toBe(fullDoc); + }); +}); + +describe("POST /v1/convert/markdown", () => { + it("returns 400 for missing markdown", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 415 for wrong content-type", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "text/plain") + .send("markdown=# hi"); + expect(res.status).toBe(415); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello World" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); +}); + +describe("POST /v1/convert/url", () => { + it("returns 400 for missing url", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({}); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid URL", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "not a url" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid URL/); + }); + + it("returns 400 for non-http protocol", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "ftp://example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/http\/https/); + }); + + it("returns 400 for private IP", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockResolvedValue({ address: "192.168.1.1", family: 4 } as any); + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://internal.example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/private/i); + }); + + it("returns 400 for DNS failure", async () => { + const dns = await import("node:dns/promises"); + vi.mocked(dns.default.lookup).mockRejectedValue(new Error("ENOTFOUND")); + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://nonexistent.example.com" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/DNS/); + }); + + it("returns PDF on success", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://example.com" }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); +});