test: add db retry and templates route tests
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m42s

This commit is contained in:
OpenClawd 2026-02-27 10:05:34 +00:00
parent aa7fe55024
commit 0d90c333c7
2 changed files with 318 additions and 0 deletions

174
src/__tests__/db.test.ts Normal file
View file

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

View file

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