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=