test: add billing and convert route tests
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m25s

This commit is contained in:
OpenClaw 2026-02-26 19:03:48 +00:00
parent 1fe3f3746a
commit f0e9a79606
2 changed files with 478 additions and 0 deletions

View file

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

View file

@ -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=<h1>hi</h1>");
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: "<h1>Hello</h1>" });
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: "<h1>Hello</h1>" });
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: "<h1>Hello</h1>" });
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/PDF_TIMEOUT/);
});
it("wraps fragments (no <html tag) with wrapHtml", async () => {
const { renderPdf } = await import("../services/browser.js");
await request(app)
.post("/v1/convert/html")
.set("content-type", "application/json")
.send({ html: "<h1>Fragment</h1>" });
// wrapHtml should have been called; renderPdf receives wrapped HTML
const calledHtml = vi.mocked(renderPdf).mock.calls[0][0];
expect(calledHtml).toContain("<html");
});
it("passes full HTML documents as-is", async () => {
const { renderPdf } = await import("../services/browser.js");
const fullDoc = "<html><body><h1>Full</h1></body></html>";
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/);
});
});