diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts
index fd23638..e16927e 100644
--- a/src/__tests__/api.test.ts
+++ b/src/__tests__/api.test.ts
@@ -43,6 +43,35 @@ describe("Health", () => {
const data = await res.json();
expect(data.status).toBe("ok");
});
+
+ it("includes database field", async () => {
+ const res = await fetch(`${BASE}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.database).toBeDefined();
+ expect(data.database.status).toBeDefined();
+ });
+
+ it("includes pool field with size, active, available", async () => {
+ const res = await fetch(`${BASE}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.pool).toBeDefined();
+ expect(data.pool.size).toBeDefined();
+ expect(data.pool.active).toBeDefined();
+ expect(data.pool.available).toBeDefined();
+ expect(typeof data.pool.size).toBe("number");
+ expect(typeof data.pool.active).toBe("number");
+ expect(typeof data.pool.available).toBe("number");
+ });
+
+ it("includes version field", async () => {
+ const res = await fetch(`${BASE}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.version).toBeDefined();
+ expect(typeof data.version).toBe("string");
+ });
});
describe("HTML to PDF", () => {
@@ -75,6 +104,93 @@ describe("HTML to PDF", () => {
});
expect(res.status).toBe(400);
});
+
+ it("converts HTML with A3 format option", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ html: "
A3 Test
",
+ options: { format: "A3" },
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ });
+
+ it("converts HTML with landscape option", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ html: "Landscape Test
",
+ options: { landscape: true },
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+
+ it("converts HTML with margin options", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ html: "Margin Test
",
+ options: { margin: { top: "2cm" } },
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+
+ it("rejects invalid JSON body", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: "invalid json{",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("rejects wrong content-type header", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "text/plain",
+ },
+ body: JSON.stringify({ html: "Test
" }),
+ });
+ expect(res.status).toBe(415);
+ });
+
+ it("handles empty html string", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ html: "" }),
+ });
+ // Empty HTML should still generate a PDF (just blank)
+ expect(res.status).toBe(200);
+ });
});
describe("Markdown to PDF", () => {
@@ -92,6 +208,156 @@ describe("Markdown to PDF", () => {
});
});
+describe("URL to PDF", () => {
+ it("converts valid URL to PDF", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ url: "https://example.com" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ const header = new Uint8Array(buf.slice(0, 5));
+ expect(String.fromCharCode(...header)).toBe("%PDF-");
+ });
+
+ it("rejects missing url field", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("url");
+ });
+
+ it("blocks private IP addresses (SSRF protection)", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ url: "http://127.0.0.1" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("private");
+ });
+
+ it("blocks localhost (SSRF protection)", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ url: "http://localhost" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("private");
+ });
+
+ it("rejects invalid protocol (ftp)", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ url: "ftp://example.com" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("http");
+ });
+
+ it("rejects invalid URL format", async () => {
+ const res = await fetch(`${BASE}/v1/convert/url`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ url: "not-a-url" }),
+ });
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("Invalid");
+ });
+});
+
+describe("Demo Endpoints", () => {
+ it("demo/html converts HTML to PDF without auth", async () => {
+ const res = await fetch(`${BASE}/v1/demo/html`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ html: "Demo Test
" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ const header = new Uint8Array(buf.slice(0, 5));
+ expect(String.fromCharCode(...header)).toBe("%PDF-");
+ });
+
+ it("demo/markdown converts markdown to PDF without auth", async () => {
+ const res = await fetch(`${BASE}/v1/demo/markdown`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ markdown: "# Demo Markdown\n\nTest content" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ });
+
+ it("demo endpoints are rate-limited", async () => {
+ // Rate limit is 5 requests per hour
+ // Make 6 rapid requests - the 6th should fail with 429
+ const requests = [];
+ for (let i = 0; i < 6; i++) {
+ const promise = fetch(`${BASE}/v1/demo/html`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ html: `Rate limit test ${i}
` }),
+ });
+ requests.push(promise);
+ }
+
+ const responses = await Promise.all(requests);
+ const statuses = responses.map((r) => r.status);
+
+ // At least one should be 429 (rate limited)
+ expect(statuses).toContain(429);
+
+ // Find the 429 response and check the error message
+ const rateLimitedResponse = responses.find((r) => r.status === 429);
+ if (rateLimitedResponse) {
+ const data = await rateLimitedResponse.json();
+ expect(data.error).toContain("limit");
+ }
+ }, 30000); // Increase timeout for this test
+});
+
describe("Templates", () => {
it("lists templates", async () => {
const res = await fetch(`${BASE}/v1/templates`, {