docfast/src/__tests__/convert.test.ts
Hoid f9caef82e6
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 1m42s
feat: add PDF render timing to convert and demo routes
- renderPdf() and renderUrlPdf() now return { pdf, durationMs }
- Timing wraps the actual render with Date.now()
- Log render duration via logger.info
- Add X-Render-Time response header in convert and demo routes
- Update all callers in convert, demo, templates routes
- Add TDD tests in render-timing.test.ts
- Update existing test mocks for new return shape
2026-03-06 11:08:06 +01:00

247 lines
8.3 KiB
TypeScript

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=<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/);
});
});
describe("PDF option validation (all endpoints)", () => {
const endpoints = [
{ path: "/v1/convert/html", body: { html: "<h1>Hi</h1>" } },
{ 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");
});
});