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({ pdf: Buffer.from("%PDF-1.4 mock"), durationMs: 10 }); vi.mocked(renderUrlPdf).mockResolvedValue({ pdf: Buffer.from("%PDF-1.4 mock url"), durationMs: 10 }); 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/); }); }); describe("PDF option validation (all endpoints)", () => { const endpoints = [ { path: "/v1/convert/html", body: { html: "

Hi

" } }, { path: "/v1/convert/markdown", body: { markdown: "# Hi" } }, ]; for (const { path, body } of endpoints) { it(`${path} returns 400 for invalid scale`, async () => { const res = await request(app) .post(path) .set("content-type", "application/json") .send({ ...body, scale: 5 }); expect(res.status).toBe(400); expect(res.body.error).toContain("scale"); }); it(`${path} returns 400 for invalid format`, async () => { const res = await request(app) .post(path) .set("content-type", "application/json") .send({ ...body, format: "B5" }); expect(res.status).toBe(400); expect(res.body.error).toContain("format"); }); it(`${path} returns 400 for non-boolean landscape`, async () => { const res = await request(app) .post(path) .set("content-type", "application/json") .send({ ...body, landscape: "yes" }); expect(res.status).toBe(400); expect(res.body.error).toContain("landscape"); }); it(`${path} returns 400 for invalid pageRanges`, async () => { const res = await request(app) .post(path) .set("content-type", "application/json") .send({ ...body, pageRanges: "abc" }); expect(res.status).toBe(400); expect(res.body.error).toContain("pageRanges"); }); it(`${path} returns 400 for invalid margin`, async () => { const res = await request(app) .post(path) .set("content-type", "application/json") .send({ ...body, margin: "1cm" }); expect(res.status).toBe(400); expect(res.body.error).toContain("margin"); }); } it("/v1/convert/url returns 400 for invalid scale", async () => { const res = await request(app) .post("/v1/convert/url") .set("content-type", "application/json") .send({ url: "https://example.com", scale: 5 }); expect(res.status).toBe(400); expect(res.body.error).toContain("scale"); }); });