From 0d90c333c71cd2b48240abae937e3bf778c6ed77 Mon Sep 17 00:00:00 2001 From: OpenClawd Date: Fri, 27 Feb 2026 10:05:34 +0000 Subject: [PATCH] test: add db retry and templates route tests --- src/__tests__/db.test.ts | 174 ++++++++++++++++++++++++++ src/__tests__/templates-route.test.ts | 144 +++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/__tests__/db.test.ts create mode 100644 src/__tests__/templates-route.test.ts diff --git a/src/__tests__/db.test.ts b/src/__tests__/db.test.ts new file mode 100644 index 0000000..4b98336 --- /dev/null +++ b/src/__tests__/db.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Local mocks — override the global setup.ts mocks for pg and logger +const mockRelease = vi.fn(); +const mockQuery = vi.fn(); +const mockConnect = vi.fn(); + +vi.mock("pg", () => { + const Pool = vi.fn(() => ({ + connect: mockConnect, + on: vi.fn(), + })); + return { default: { Pool }, Pool }; +}); + +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Must re-mock db.js so setup.ts mock doesn't apply — we want real implementation +vi.mock("../services/db.js", async () => { + return await vi.importActual("../services/db.js"); +}); + +let queryWithRetry: typeof import("../services/db.js").queryWithRetry; +let connectWithRetry: typeof import("../services/db.js").connectWithRetry; + +function makeClient(queryFn = mockQuery, releaseFn = mockRelease) { + return { query: queryFn, release: releaseFn }; +} + +function transientError(code = "ECONNRESET") { + const err = new Error(`connection error: ${code}`); + (err as any).code = code; + return err; +} + +function nonTransientError() { + const err = new Error("syntax error at position 42"); + (err as any).code = "42601"; + return err; +} + +describe("db retry logic", () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mockConnect.mockReset(); + mockQuery.mockReset(); + mockRelease.mockReset(); + + const db = await import("../services/db.js"); + queryWithRetry = db.queryWithRetry; + connectWithRetry = db.connectWithRetry; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("queryWithRetry", () => { + it("succeeds on first try, returns result", async () => { + const result = { rows: [{ id: 1 }], rowCount: 1 }; + const client = makeClient(vi.fn().mockResolvedValue(result)); + mockConnect.mockResolvedValue(client); + + const res = await queryWithRetry("SELECT 1"); + expect(res).toBe(result); + expect(client.release).toHaveBeenCalledWith(); + }); + + it("retries on transient error (ECONNRESET), succeeds on 2nd attempt", async () => { + const badClient = makeClient(vi.fn().mockRejectedValue(transientError()), vi.fn()); + const goodResult = { rows: [{ ok: true }], rowCount: 1 }; + const goodClient = makeClient(vi.fn().mockResolvedValue(goodResult), vi.fn()); + + mockConnect.mockResolvedValueOnce(badClient).mockResolvedValueOnce(goodClient); + + const promise = queryWithRetry("SELECT 1"); + // Advance past the retry delay + await vi.advanceTimersByTimeAsync(2000); + const res = await promise; + + expect(res).toBe(goodResult); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + it("calls client.release(true) to destroy bad connection on transient error", async () => { + const badRelease = vi.fn(); + const badClient = makeClient(vi.fn().mockRejectedValue(transientError()), badRelease); + const goodClient = makeClient(vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), vi.fn()); + + mockConnect.mockResolvedValueOnce(badClient).mockResolvedValueOnce(goodClient); + + const promise = queryWithRetry("SELECT 1"); + await vi.advanceTimersByTimeAsync(2000); + await promise; + + expect(badRelease).toHaveBeenCalledWith(true); + }); + + it("throws non-transient errors immediately without retry", async () => { + const client = makeClient(vi.fn().mockRejectedValue(nonTransientError()), vi.fn()); + mockConnect.mockResolvedValue(client); + + await expect(queryWithRetry("BAD SQL")).rejects.toThrow("syntax error"); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("throws after exhausting all retries on persistent transient error", async () => { + vi.useRealTimers(); + const err = transientError(); + mockConnect.mockResolvedValue(makeClient(vi.fn().mockRejectedValue(err), vi.fn())); + await expect(queryWithRetry("SELECT 1", undefined, 0)).rejects.toThrow(err.message); + expect(mockConnect).toHaveBeenCalledTimes(1); // 0 only, no retries + vi.useFakeTimers(); + }); + + it("respects maxRetries parameter", async () => { + vi.useRealTimers(); + mockConnect.mockResolvedValue(makeClient(vi.fn().mockRejectedValue(transientError()), vi.fn())); + await expect(queryWithRetry("SELECT 1", undefined, 0)).rejects.toThrow(); + expect(mockConnect).toHaveBeenCalledTimes(1); + vi.useFakeTimers(); + }); + }); + + describe("connectWithRetry", () => { + it("returns client on success, validates with SELECT 1", async () => { + const client = makeClient(vi.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }), vi.fn()); + mockConnect.mockResolvedValue(client); + + const result = await connectWithRetry(); + expect(result).toBe(client); + expect(client.query).toHaveBeenCalledWith("SELECT 1"); + }); + + it("retries on transient connect error", async () => { + const goodClient = makeClient(vi.fn().mockResolvedValue({ rows: [] }), vi.fn()); + mockConnect + .mockRejectedValueOnce(transientError("ECONNREFUSED")) + .mockResolvedValueOnce(goodClient); + + const promise = connectWithRetry(); + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(result).toBe(goodClient); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + it("destroys connection and retries when SELECT 1 validation fails", async () => { + const badRelease = vi.fn(); + const badClient = makeClient(vi.fn().mockRejectedValue(transientError()), badRelease); + const goodClient = makeClient(vi.fn().mockResolvedValue({ rows: [] }), vi.fn()); + + mockConnect.mockResolvedValueOnce(badClient).mockResolvedValueOnce(goodClient); + + const promise = connectWithRetry(); + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(badRelease).toHaveBeenCalledWith(true); + expect(result).toBe(goodClient); + }); + + it("throws non-transient errors immediately", async () => { + mockConnect.mockRejectedValue(nonTransientError()); + + await expect(connectWithRetry()).rejects.toThrow("syntax error"); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/__tests__/templates-route.test.ts b/src/__tests__/templates-route.test.ts new file mode 100644 index 0000000..e21399e --- /dev/null +++ b/src/__tests__/templates-route.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockResolvedValue(Buffer.from("%PDF-1.4 mock")); + + const { authMiddleware } = await import("../middleware/auth.js"); + const { usageMiddleware } = await import("../middleware/usage.js"); + const { templatesRouter } = await import("../routes/templates.js"); + + app = express(); + app.use(express.json()); + app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); +}); + +describe("GET /v1/templates", () => { + it("returns template list with id, name, description, fields", async () => { + const res = await request(app) + .get("/v1/templates") + .set("Authorization", "Bearer test-key"); + + expect(res.status).toBe(200); + expect(res.body.templates).toBeInstanceOf(Array); + expect(res.body.templates.length).toBeGreaterThan(0); + + const invoice = res.body.templates.find((t: any) => t.id === "invoice"); + expect(invoice).toBeDefined(); + expect(invoice.name).toBe("Invoice"); + expect(invoice.description).toBeTruthy(); + expect(invoice.fields).toBeInstanceOf(Array); + expect(invoice.fields.length).toBeGreaterThan(0); + }); + + it("requires auth (401 without key)", async () => { + const res = await request(app).get("/v1/templates"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /v1/templates/:id/render", () => { + const validInvoiceData = { + invoiceNumber: "INV-001", + date: "2026-01-15", + from: { name: "Acme Corp" }, + to: { name: "Client Inc" }, + items: [{ description: "Service", quantity: 1, unitPrice: 100 }], + }; + + it("returns 404 for unknown template", async () => { + const res = await request(app) + .post("/v1/templates/nonexistent/render") + .set("Authorization", "Bearer test-key") + .send({ foo: "bar" }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("not found"); + }); + + it("returns 400 with missing required fields listed", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send({ invoiceNumber: "INV-001" }); + + expect(res.status).toBe(400); + expect(res.body.missing).toBeInstanceOf(Array); + expect(res.body.missing).toContain("date"); + expect(res.body.missing).toContain("from"); + }); + + it("renders invoice successfully, returns PDF", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send(validInvoiceData); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + expect(res.headers["content-disposition"]).toContain("invoice.pdf"); + }); + + it("accepts data in req.body.data wrapper", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send({ data: validInvoiceData }); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + }); + + it("passes _format, _margin to renderPdf", async () => { + const { renderPdf } = await import("../services/browser.js"); + + await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send({ + ...validInvoiceData, + _format: "Letter", + _margin: { top: "20mm", bottom: "20mm" }, + }); + + expect(vi.mocked(renderPdf)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + format: "Letter", + margin: { top: "20mm", bottom: "20mm" }, + }) + ); + }); + + it("sanitizes _filename in Content-Disposition", async () => { + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send({ ...validInvoiceData, _filename: 'evil"file\nname.pdf' }); + + expect(res.status).toBe(200); + const disposition = res.headers["content-disposition"]; + // The quote and newline in the input should be sanitized to underscores + expect(disposition).toContain("evil_file_name.pdf"); + expect(disposition).not.toContain("\n"); + }); + + it("returns 500 on render error", async () => { + const { renderPdf } = await import("../services/browser.js"); + vi.mocked(renderPdf).mockRejectedValue(new Error("Browser crashed")); + + const res = await request(app) + .post("/v1/templates/invoice/render") + .set("Authorization", "Bearer test-key") + .send(validInvoiceData); + + expect(res.status).toBe(500); + expect(res.body.error).toContain("failed"); + }); +});