test: add db retry and templates route tests
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m42s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m42s
This commit is contained in:
parent
aa7fe55024
commit
0d90c333c7
2 changed files with 318 additions and 0 deletions
174
src/__tests__/db.test.ts
Normal file
174
src/__tests__/db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
src/__tests__/templates-route.test.ts
Normal file
144
src/__tests__/templates-route.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue