From 91ed161713d686e5853b3d8f277b27682f999e04 Mon Sep 17 00:00:00 2001 From: Alex Benjamin Date: Mon, 19 Oct 2026 20:07:20 +0100 Subject: [PATCH] added route-level tests for src/routes/outbox.routes.ts --- .../src/__tests__/auditTrail.routes.test.ts | 531 +++++++++++++++ backend/src/__tests__/dispute.routes.test.ts | 554 ++++++++++++++++ .../src/__tests__/job-health.routes.test.ts | 603 +++++++++++++++++ backend/src/__tests__/outbox.routes.test.ts | 623 ++++++++++++++++++ 4 files changed, 2311 insertions(+) create mode 100644 backend/src/__tests__/auditTrail.routes.test.ts create mode 100644 backend/src/__tests__/dispute.routes.test.ts create mode 100644 backend/src/__tests__/job-health.routes.test.ts create mode 100644 backend/src/__tests__/outbox.routes.test.ts diff --git a/backend/src/__tests__/auditTrail.routes.test.ts b/backend/src/__tests__/auditTrail.routes.test.ts new file mode 100644 index 00000000..d7f322d1 --- /dev/null +++ b/backend/src/__tests__/auditTrail.routes.test.ts @@ -0,0 +1,531 @@ +/** + * Route-level tests for src/routes/auditTrail.routes.ts + * + * Covers both handlers: + * GET /trades/:id/history + * GET /trades/:id/history/verify + * + * Each handler is tested for: + * - Happy path (200 with correct shape) + * - Validation failure (400 missing required query param) + * - Auth failure (401 no token / 401 bad token) + * - Access denied (403) + * - Not found (404) + * - Config error (500 from AuditSigningConfigError) + * - Unexpected service error (500 generic) + */ + +// Must be set before any module that reads env is imported +process.env.JWT_SECRET = "test-secret-at-least-32-characters-long!!"; +process.env.JWT_ISSUER = "amana"; +process.env.JWT_AUDIENCE = "amana-api"; +process.env.DATABASE_URL = "postgresql://localhost:5432/amana_test"; +process.env.AMANA_ESCROW_CONTRACT_ID = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; +process.env.USDC_CONTRACT_ID = + "CBIELTK6YBZJU5UP2WWQEUCY7PUJE7R6OB3NIUJKL5UH4WJQJ6HVHKX"; + +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +import { createAuditTrailRouter } from "../routes/auditTrail.routes"; +import { + AuditTrailService, + AuditTrailAccessDeniedError, + AuditTrailTradeNotFoundError, + AuditSigningConfigError, + type TradeEvent, + type CanonicalAuditPayload, + type AuditIntegrityMetadata, +} from "../services/auditTrail.service"; +import { AuthService } from "../services/auth.service"; +import { errorHandler } from "../middleware/errorHandler"; + +// --------------------------------------------------------------------------- +// Global auth service mocks — prevent real Redis / DB calls in authMiddleware +// --------------------------------------------------------------------------- +jest.spyOn(AuthService, "isTokenRevoked").mockResolvedValue(false); +jest.spyOn(AuthService, "getTokenVersion").mockResolvedValue(0); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const BUYER = "GCBUYER00000000000000000000000000000000000000000000000001"; +const STRANGER = "GCSTRANGER000000000000000000000000000000000000000000000001"; +const TRADE_ID = "trade-abc-123"; + +const JWT_SECRET = process.env.JWT_SECRET; +const JWT_ISSUER = process.env.JWT_ISSUER; +const JWT_AUDIENCE = process.env.JWT_AUDIENCE; + +function makeToken(walletAddress: string): string { + return jwt.sign( + { + walletAddress, + jti: `jti-${walletAddress}-${Math.random()}`, + nbf: Math.floor(Date.now() / 1000) - 1, + }, + JWT_SECRET!, + { algorithm: "HS256", issuer: JWT_ISSUER, audience: JWT_AUDIENCE }, + ); +} + +const t1 = new Date("2026-01-01T10:00:00.000Z"); +const t2 = new Date("2026-01-02T10:00:00.000Z"); + +const MOCK_EVENTS: TradeEvent[] = [ + { eventType: "CREATED", timestamp: t1, actor: BUYER, metadata: { amountUsdc: "100" } }, + { eventType: "FUNDED", timestamp: t2, actor: BUYER, metadata: {} }, +]; + +const MOCK_PAYLOAD: CanonicalAuditPayload = { + tradeId: TRADE_ID, + generatedAt: "2026-01-03T00:00:00.000Z", + events: [ + { eventType: "CREATED", timestamp: t1.toISOString(), actor: BUYER, metadata: { amountUsdc: "100" } }, + { eventType: "FUNDED", timestamp: t2.toISOString(), actor: BUYER, metadata: {} }, + ], +}; + +const MOCK_INTEGRITY: AuditIntegrityMetadata = { + algorithm: "ed25519", + keyId: "key-1", + payloadHash: "deadbeef", + signature: "c2lnbmF0dXJl", +}; + +// --------------------------------------------------------------------------- +// Test app factory — injects a fresh mock service for each test +// --------------------------------------------------------------------------- +function buildApp(service: Partial) { + const app = express(); + app.use(express.json()); + app.use("/trades", createAuditTrailRouter(service as AuditTrailService)); + app.use(errorHandler); + return app; +} + +// --------------------------------------------------------------------------- +// Helper: build a minimal mock service with sane defaults +// --------------------------------------------------------------------------- +function mockService(overrides: Partial> = {}): jest.Mocked { + return { + getTradeHistory: jest.fn().mockResolvedValue(MOCK_EVENTS), + getCanonicalPayload: jest.fn().mockReturnValue(MOCK_PAYLOAD), + signPayload: jest.fn().mockReturnValue(MOCK_INTEGRITY), + verifyPayload: jest.fn().mockReturnValue(true), + logScanReport: jest.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as jest.Mocked; +} + +// =========================================================================== +// GET /trades/:id/history +// =========================================================================== +describe("GET /trades/:id/history", () => { + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failures + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 401 when Authorization header is absent", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history`); + + expect(res.status).toBe(401); + }); + + it("returns 401 when token is malformed", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", "Bearer not.a.real.jwt"); + + expect(res.status).toBe(401); + }); + + it("returns 401 when Bearer scheme is missing", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", makeToken(BUYER)); // no "Bearer " prefix + + expect(res.status).toBe(401); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — JSON (default) + // ------------------------------------------------------------------------- + describe("happy path — JSON", () => { + it("returns 200 with events array and correct format field", async () => { + const svc = mockService(); + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.format).toBe("json"); + expect(Array.isArray(res.body.events)).toBe(true); + expect(res.body.events).toHaveLength(MOCK_EVENTS.length); + }); + + it("calls the service with the correct tradeId and callerAddress", async () => { + const svc = mockService(); + await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(svc.getTradeHistory).toHaveBeenCalledWith(TRADE_ID, BUYER); + }); + + it("does not include integrity or canonicalPayload when signed is not set", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.body.integrity).toBeUndefined(); + expect(res.body.canonicalPayload).toBeUndefined(); + }); + + it("includes integrity and canonicalPayload when signed=true", async () => { + const svc = mockService(); + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history?signed=true`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.integrity).toEqual(MOCK_INTEGRITY); + expect(res.body.canonicalPayload).toEqual(MOCK_PAYLOAD); + expect(svc.signPayload).toHaveBeenCalledWith(MOCK_PAYLOAD); + }); + + it("each event has the required shape fields", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + const event = res.body.events[0]; + expect(event).toHaveProperty("eventType"); + expect(event).toHaveProperty("timestamp"); + expect(event).toHaveProperty("actor"); + expect(event).toHaveProperty("metadata"); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — CSV export + // ------------------------------------------------------------------------- + describe("happy path — CSV export (?format=csv)", () => { + it("returns 200 with text/csv content type", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history?format=csv`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + }); + + it("sets Content-Disposition with the correct filename", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history?format=csv`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.headers["content-disposition"]).toContain( + `trade-${TRADE_ID}-history.csv`, + ); + }); + + it("CSV header row contains all required columns", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history?format=csv`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + const header = res.text.split("\n")[0]; + expect(header).toContain("eventType"); + expect(header).toContain("timestamp"); + expect(header).toContain("actor"); + expect(header).toContain("metadata"); + }); + + it("CSV has one data row per event", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history?format=csv`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + const nonEmptyLines = res.text.split("\n").filter((l: string) => l.trim() !== ""); + // +1 for the header row + expect(nonEmptyLines.length).toBe(MOCK_EVENTS.length + 1); + }); + + it("returns JSON envelope (not plain CSV) when format=csv AND signed=true", async () => { + const svc = mockService(); + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history?format=csv&signed=true`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.format).toBe("csv"); + expect(typeof res.body.csv).toBe("string"); + expect(res.body.integrity).toEqual(MOCK_INTEGRITY); + expect(res.body.canonicalPayload).toEqual(MOCK_PAYLOAD); + }); + }); + + // ------------------------------------------------------------------------- + // Access denied + // ------------------------------------------------------------------------- + describe("access denied", () => { + it("returns 403 when caller is not a party to the trade", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new AuditTrailAccessDeniedError()), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(STRANGER)}`); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/access denied/i); + }); + }); + + // ------------------------------------------------------------------------- + // Not found + // ------------------------------------------------------------------------- + describe("not found", () => { + it("returns 404 when the trade does not exist", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new AuditTrailTradeNotFoundError()), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/nonexistent/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + }); + + // ------------------------------------------------------------------------- + // Service / config errors + // ------------------------------------------------------------------------- + describe("server errors", () => { + it("returns 500 with config error message when signing config is missing", async () => { + const svc = mockService({ + signPayload: jest.fn().mockImplementation(() => { + throw new AuditSigningConfigError("AUDIT_SIGNING_KEY_ID and AUDIT_SIGNING_PRIVATE_KEY_PEM are required"); + }), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history?signed=true`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/AUDIT_SIGNING/i); + }); + + it("returns 500 for unexpected service errors", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new Error("Unexpected DB failure")), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Failed to retrieve trade history"); + }); + }); +}); + +// =========================================================================== +// GET /trades/:id/history/verify +// =========================================================================== +describe("GET /trades/:id/history/verify", () => { + const VALID_SIGNATURE = Buffer.from("fakesig").toString("base64"); + + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failures + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 401 when Authorization header is absent", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`); + + expect(res.status).toBe(401); + }); + + it("returns 401 when token is malformed", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", "Bearer garbage.token.here"); + + expect(res.status).toBe(401); + }); + }); + + // ------------------------------------------------------------------------- + // Validation failure — missing required query param + // ------------------------------------------------------------------------- + describe("validation failure", () => { + it("returns 400 when signature query param is missing", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history/verify`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/missing signature/i); + }); + + it("returns 400 when signature query param is empty string", async () => { + const res = await request(buildApp(mockService())) + .get(`/trades/${TRADE_ID}/history/verify?signature=`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + // empty string is falsy — treated as missing + expect(res.status).toBe(400); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + describe("happy path", () => { + it("returns 200 with valid=true and expected shape when signature is correct", async () => { + process.env.AUDIT_SIGNING_KEY_ID = "key-1"; + const svc = mockService({ verifyPayload: jest.fn().mockReturnValue(true) }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(res.body.algorithm).toBe("ed25519"); + expect(typeof res.body.payloadHash).toBe("string"); + expect(res.body.payloadHash.length).toBeGreaterThan(0); + }); + + it("returns valid=false when signature does not match", async () => { + process.env.AUDIT_SIGNING_KEY_ID = "key-1"; + const svc = mockService({ verifyPayload: jest.fn().mockReturnValue(false) }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + }); + + it("passes the correct tradeId and callerAddress to getTradeHistory", async () => { + process.env.AUDIT_SIGNING_KEY_ID = "key-1"; + const svc = mockService(); + + await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(svc.getTradeHistory).toHaveBeenCalledWith(TRADE_ID, BUYER); + }); + + it("reflects the keyId from env in the response", async () => { + process.env.AUDIT_SIGNING_KEY_ID = "my-rotation-key"; + const svc = mockService(); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.keyId).toBe("my-rotation-key"); + }); + + it("returns keyId as null when AUDIT_SIGNING_KEY_ID is not set", async () => { + delete process.env.AUDIT_SIGNING_KEY_ID; + const svc = mockService(); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(200); + expect(res.body.keyId).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Access denied + // ------------------------------------------------------------------------- + describe("access denied", () => { + it("returns 403 when caller is not a party to the trade", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new AuditTrailAccessDeniedError()), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(STRANGER)}`); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/access denied/i); + }); + }); + + // ------------------------------------------------------------------------- + // Not found + // ------------------------------------------------------------------------- + describe("not found", () => { + it("returns 404 when the trade does not exist", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new AuditTrailTradeNotFoundError()), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/nonexistent/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + }); + + // ------------------------------------------------------------------------- + // Config / server errors + // ------------------------------------------------------------------------- + describe("server errors", () => { + it("returns 500 with config error message when public key is missing", async () => { + const svc = mockService({ + verifyPayload: jest.fn().mockImplementation(() => { + throw new AuditSigningConfigError("AUDIT_SIGNING_PUBLIC_KEY_PEM is required"); + }), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/AUDIT_SIGNING_PUBLIC_KEY_PEM/i); + }); + + it("returns 500 for unexpected service errors", async () => { + const svc = mockService({ + getTradeHistory: jest.fn().mockRejectedValue(new Error("Connection reset")), + }); + + const res = await request(buildApp(svc)) + .get(`/trades/${TRADE_ID}/history/verify?signature=${VALID_SIGNATURE}`) + .set("Authorization", `Bearer ${makeToken(BUYER)}`); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Failed to verify trade history signature"); + }); + }); +}); diff --git a/backend/src/__tests__/dispute.routes.test.ts b/backend/src/__tests__/dispute.routes.test.ts new file mode 100644 index 00000000..dae6522b --- /dev/null +++ b/backend/src/__tests__/dispute.routes.test.ts @@ -0,0 +1,554 @@ +/** + * Route-level tests for src/routes/dispute.routes.ts + * + * Handlers under test: + * GET /disputes/ — listMediatorDisputes + * POST /disputes/:id/transition — transitionDisputeStatus + * + * Each handler is exercised for: + * - Happy path (2xx with correct response shape) + * - Validation failure (400 — invalid / missing body or query params) + * - Auth failure (401 — no token, malformed token) + * - Access denied (403 — non-mediator caller) + * - Not found (404 — dispute not found) + * - Unexpected service error (500) + */ + +// Env vars must be set before any module that reads them at import time +process.env.JWT_SECRET = "test-secret-at-least-32-characters-long!!"; +process.env.JWT_ISSUER = "amana"; +process.env.JWT_AUDIENCE = "amana-api"; +process.env.DATABASE_URL = "postgresql://localhost:5432/amana_test"; +process.env.AMANA_ESCROW_CONTRACT_ID = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; +process.env.USDC_CONTRACT_ID = + "CBIELTK6YBZJU5UP2WWQEUCY7PUJE7R6OB3NIUJKL5UH4WJQJ6HVHKX"; + +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +import { DisputeController, createDisputeRouter } from "../controllers/dispute.controller"; +import { DisputeService, type DisputeListResponse, type DisputeResponse } from "../services/dispute.service"; +import { AuthService } from "../services/auth.service"; +import { AppError, ErrorCode } from "../errors/errorCodes"; +import { errorHandler } from "../middleware/errorHandler"; + +// --------------------------------------------------------------------------- +// Prevent real Redis / DB calls inside authMiddleware +// --------------------------------------------------------------------------- +jest.spyOn(AuthService, "isTokenRevoked").mockResolvedValue(false); +jest.spyOn(AuthService, "getTokenVersion").mockResolvedValue(0); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const MEDIATOR = "GCMEDIATOR0000000000000000000000000000000000000000000001"; +const NON_MEDIATOR = "GCUSER00000000000000000000000000000000000000000000000001"; +const TRADE_ID = "trade-dispute-001"; + +const JWT_SECRET = process.env.JWT_SECRET!; +const JWT_ISSUER = process.env.JWT_ISSUER!; +const JWT_AUDIENCE = process.env.JWT_AUDIENCE!; + +function makeToken(walletAddress: string): string { + return jwt.sign( + { + walletAddress, + jti: `jti-${walletAddress}-${Math.random()}`, + nbf: Math.floor(Date.now() / 1000) - 1, + }, + JWT_SECRET, + { algorithm: "HS256", issuer: JWT_ISSUER, audience: JWT_AUDIENCE }, + ); +} + +// --------------------------------------------------------------------------- +// Shared mock dispute fixture +// --------------------------------------------------------------------------- +const MOCK_DISPUTE: DisputeResponse = { + id: 1, + tradeId: TRADE_ID, + initiator: NON_MEDIATOR, + reason: "Goods not delivered", + status: "OPEN", + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-01-01T10:00:00.000Z", + resolvedAt: null, + trade: { + buyerAddress: NON_MEDIATOR, + sellerAddress: "GCSELLER0000000000000000000000000000000000000000000000001", + amountUsdc: "500", + }, +}; + +const MOCK_LIST_RESPONSE: DisputeListResponse = { + items: [MOCK_DISPUTE], + pagination: { page: 1, limit: 10, total: 1, totalPages: 1 }, +}; + +// --------------------------------------------------------------------------- +// Test app factory — injects a fresh mock Prisma so the service never hits DB +// --------------------------------------------------------------------------- +function buildApp(serviceOverrides: Partial> = {}) { + // Build a mock Prisma client; the controller factory accepts a prisma instance + // but we bypass DB entirely by mocking the service methods via the controller. + const mockPrisma = {} as never; + const app = express(); + app.use(express.json()); + + // We inject mock behaviour by replacing methods on the service instance that + // createDisputeRouter constructs internally. To do this cleanly, we spy on + // the DisputeService prototype before constructing the router. + const listSpy = jest + .spyOn(DisputeService.prototype, "listMediatorDisputes") + .mockImplementation( + serviceOverrides.listMediatorDisputes ?? + ((_addr: string, _params?: unknown) => Promise.resolve(MOCK_LIST_RESPONSE)), + ); + const transitionSpy = jest + .spyOn(DisputeService.prototype, "transitionDisputeStatus") + .mockImplementation( + serviceOverrides.transitionDisputeStatus ?? + ((_tradeId: string, _addr: string, _status: unknown) => + Promise.resolve({ ...MOCK_DISPUTE, status: "UNDER_REVIEW" as const })), + ); + + app.use("/disputes", createDisputeRouter(mockPrisma)); + app.use(errorHandler); + + return { app, listSpy, transitionSpy }; +} + +// =========================================================================== +// GET /disputes — listMediatorDisputes +// =========================================================================== +describe("GET /disputes", () => { + afterEach(() => jest.restoreAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failures + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 401 when Authorization header is absent", async () => { + const { app } = buildApp(); + const res = await request(app).get("/disputes"); + expect(res.status).toBe(401); + }); + + it("returns 401 for a malformed JWT", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes") + .set("Authorization", "Bearer not.a.jwt"); + expect(res.status).toBe(401); + }); + + it("returns 401 when Bearer scheme is missing", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes") + .set("Authorization", makeToken(MEDIATOR)); // raw token, no prefix + expect(res.status).toBe(401); + }); + }); + + // ------------------------------------------------------------------------- + // Validation failures — invalid query params + // ------------------------------------------------------------------------- + describe("validation failure", () => { + it("returns 400 when status is not a valid enum value", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes?status=INVALID_STATUS") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCode.VALIDATION_ERROR); + }); + + it("returns 400 when page is not a number", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes?page=abc") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + expect(res.status).toBe(400); + }); + + it("returns 400 when limit exceeds 100", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes?limit=101") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + expect(res.status).toBe(400); + }); + + it("returns 400 when page is less than 1", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes?page=0") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + expect(res.status).toBe(400); + }); + }); + + // ------------------------------------------------------------------------- + // Access denied + // ------------------------------------------------------------------------- + describe("access denied", () => { + it("returns 403 when caller is not in the mediator allowlist", async () => { + const { app } = buildApp({ + listMediatorDisputes: jest.fn().mockRejectedValue( + new AppError(ErrorCode.AUTH_ERROR, "Unauthorized: Not a mediator", 403), + ), + }); + + const res = await request(app) + .get("/disputes") + .set("Authorization", `Bearer ${makeToken(NON_MEDIATOR)}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe(ErrorCode.AUTH_ERROR); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + describe("happy path", () => { + it("returns 200 with items array and pagination", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.items)).toBe(true); + expect(res.body.pagination).toMatchObject({ + page: 1, + limit: 10, + total: 1, + totalPages: 1, + }); + }); + + it("each item has the expected dispute shape", async () => { + const { app } = buildApp(); + const res = await request(app) + .get("/disputes") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + + const item = res.body.items[0]; + expect(item).toHaveProperty("id"); + expect(item).toHaveProperty("tradeId"); + expect(item).toHaveProperty("initiator"); + expect(item).toHaveProperty("reason"); + expect(item).toHaveProperty("status"); + expect(item).toHaveProperty("createdAt"); + expect(item).toHaveProperty("trade"); + }); + + it("passes status filter to the service when provided", async () => { + const { app, listSpy } = buildApp(); + await request(app) + .get("/disputes?status=OPEN") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + + expect(listSpy).toHaveBeenCalledWith( + MEDIATOR, + expect.objectContaining({ status: "OPEN" }), + ); + }); + + it("passes page and limit to the service when provided", async () => { + const { app, listSpy } = buildApp(); + await request(app) + .get("/disputes?page=2&limit=5") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + + expect(listSpy).toHaveBeenCalledWith( + MEDIATOR, + expect.objectContaining({ page: 2, limit: 5 }), + ); + }); + + it("returns 200 with all valid status filter values", async () => { + const validStatuses = ["OPEN", "UNDER_REVIEW", "RESOLVED", "CLOSED"]; + for (const status of validStatuses) { + const { app } = buildApp(); + const res = await request(app) + .get(`/disputes?status=${status}`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + expect(res.status).toBe(200); + jest.restoreAllMocks(); + } + }); + }); + + // ------------------------------------------------------------------------- + // Server errors + // ------------------------------------------------------------------------- + describe("server errors", () => { + it("returns 500 for unexpected service errors", async () => { + const { app } = buildApp({ + listMediatorDisputes: jest.fn().mockRejectedValue(new Error("DB pool exhausted")), + }); + + const res = await request(app) + .get("/disputes") + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`); + + expect(res.status).toBe(500); + }); + }); +}); + +// =========================================================================== +// POST /disputes/:id/transition — transitionDisputeStatus +// =========================================================================== +describe("POST /disputes/:id/transition", () => { + afterEach(() => jest.restoreAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failures + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 401 when Authorization header is absent", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .send({ status: "UNDER_REVIEW" }); + expect(res.status).toBe(401); + }); + + it("returns 401 for a malformed JWT", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", "Bearer garbage.token") + .send({ status: "UNDER_REVIEW" }); + expect(res.status).toBe(401); + }); + }); + + // ------------------------------------------------------------------------- + // Validation failures — invalid body + // ------------------------------------------------------------------------- + describe("validation failure", () => { + it("returns 400 when status field is missing from body", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCode.VALIDATION_ERROR); + }); + + it("returns 400 when status is not a valid transition value", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "OPEN" }); // OPEN is not a valid transition target per schema + + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCode.VALIDATION_ERROR); + }); + + it("returns 400 when status is an arbitrary string", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "INVALID" }); + + expect(res.status).toBe(400); + }); + + it("returns 400 when body is not JSON", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .set("Content-Type", "application/json") + .send("not-json"); + + expect(res.status).toBe(400); + }); + }); + + // ------------------------------------------------------------------------- + // Access denied + // ------------------------------------------------------------------------- + describe("access denied", () => { + it("returns 403 when caller is not in the mediator allowlist", async () => { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockRejectedValue( + new AppError(ErrorCode.AUTH_ERROR, "Unauthorized: Not a mediator", 403), + ), + }); + + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(NON_MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe(ErrorCode.AUTH_ERROR); + }); + }); + + // ------------------------------------------------------------------------- + // Not found + // ------------------------------------------------------------------------- + describe("not found", () => { + it("returns 404 when no dispute exists for the given trade", async () => { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockRejectedValue( + new AppError( + ErrorCode.DISPUTE_NOT_FOUND, + `No dispute found for trade: ${TRADE_ID}`, + 404, + ), + ), + }); + + const res = await request(app) + .post(`/disputes/nonexistent-trade/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.status).toBe(404); + expect(res.body.code).toBe(ErrorCode.DISPUTE_NOT_FOUND); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + describe("happy path", () => { + it("returns 200 with the updated dispute when transition is valid", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.status).toBe(200); + expect(res.body.tradeId).toBe(TRADE_ID); + expect(res.body.status).toBe("UNDER_REVIEW"); + }); + + it("returns the full dispute shape in the response", async () => { + const { app } = buildApp(); + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.body).toHaveProperty("id"); + expect(res.body).toHaveProperty("tradeId"); + expect(res.body).toHaveProperty("initiator"); + expect(res.body).toHaveProperty("status"); + expect(res.body).toHaveProperty("createdAt"); + expect(res.body).toHaveProperty("trade"); + }); + + it("passes tradeId and callerAddress to the service", async () => { + const { app, transitionSpy } = buildApp(); + await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "RESOLVED" }); + + expect(transitionSpy).toHaveBeenCalledWith(TRADE_ID, MEDIATOR, "RESOLVED"); + }); + + it("accepts all valid transition target statuses", async () => { + const validTargets = ["UNDER_REVIEW", "RESOLVED", "CLOSED"] as const; + for (const status of validTargets) { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockResolvedValue({ + ...MOCK_DISPUTE, + status, + }), + }); + + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe(status); + jest.restoreAllMocks(); + } + }); + }); + + // ------------------------------------------------------------------------- + // Domain / conflict errors + // ------------------------------------------------------------------------- + describe("domain errors", () => { + it("returns 409 for an invalid status transition", async () => { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockRejectedValue( + new AppError( + ErrorCode.DISPUTE_STATUS_TRANSITION_INVALID, + "Cannot transition from RESOLVED to UNDER_REVIEW", + 409, + ), + ), + }); + + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe(ErrorCode.DISPUTE_STATUS_TRANSITION_INVALID); + }); + + it("returns 409 for a concurrent update conflict", async () => { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockRejectedValue( + new AppError( + ErrorCode.DISPUTE_STATUS_CONFLICT, + "Dispute was modified concurrently", + 409, + ), + ), + }); + + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "RESOLVED" }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe(ErrorCode.DISPUTE_STATUS_CONFLICT); + }); + }); + + // ------------------------------------------------------------------------- + // Server errors + // ------------------------------------------------------------------------- + describe("server errors", () => { + it("returns 500 for unexpected service errors", async () => { + const { app } = buildApp({ + transitionDisputeStatus: jest.fn().mockRejectedValue( + new Error("Unexpected DB failure"), + ), + }); + + const res = await request(app) + .post(`/disputes/${TRADE_ID}/transition`) + .set("Authorization", `Bearer ${makeToken(MEDIATOR)}`) + .send({ status: "UNDER_REVIEW" }); + + expect(res.status).toBe(500); + }); + }); +}); diff --git a/backend/src/__tests__/job-health.routes.test.ts b/backend/src/__tests__/job-health.routes.test.ts new file mode 100644 index 00000000..7ad2d003 --- /dev/null +++ b/backend/src/__tests__/job-health.routes.test.ts @@ -0,0 +1,603 @@ +/** + * Route-level tests for src/routes/job-health.routes.ts + * + * Handlers under test (all mounted at /health/*): + * GET /health/jobs — list all job health + * GET /health/jobs/:jobType — single job health + * GET /health/dashboard — analytics dashboard + * POST /health/jobs/:jobType/manual-check — manual health check trigger + * POST /health/jobs/:jobType/reset-failures — admin failure-count reset + * + * Each handler is exercised for: + * - Happy path (correct status code + response shape) + * - "Validation" failure (unknown jobType → 404) + * - Service / DB error (→ 500) + * + * Note: the isAdmin middleware in this file is currently a transparent passthrough + * (next() only), so there is no auth gate to test at the route level. + */ + +process.env.JWT_SECRET = "test-secret-at-least-32-characters-long!!"; +process.env.JWT_ISSUER = "amana"; +process.env.JWT_AUDIENCE = "amana-api"; +process.env.DATABASE_URL = "postgresql://localhost:5432/amana_test"; +process.env.AMANA_ESCROW_CONTRACT_ID = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; +process.env.USDC_CONTRACT_ID = + "CBIELTK6YBZJU5UP2WWQEUCY7PUJE7R6OB3NIUJKL5UH4WJQJ6HVHKX"; + +import express from "express"; +import request from "supertest"; + +import { createJobHealthRoutes } from "../routes/job-health.routes"; +import { jobHeartbeatService, JobType, type JobHealthStatus } from "../services/jobHeartbeat.service"; +import { prisma } from "../lib/db"; + +// --------------------------------------------------------------------------- +// Module-level mocks — prevent real DB / service calls +// --------------------------------------------------------------------------- +jest.mock("../services/jobHeartbeat.service", () => { + const actual = jest.requireActual("../services/jobHeartbeat.service"); + return { + ...actual, + jobHeartbeatService: { + getAllJobHealth: jest.fn(), + getJobHealth: jest.fn(), + }, + }; +}); + +jest.mock("../lib/db", () => ({ + prisma: { + jobHeartbeat: { + findMany: jest.fn(), + update: jest.fn(), + }, + }, +})); + +// --------------------------------------------------------------------------- +// Typed aliases for mocked functions +// --------------------------------------------------------------------------- +const mockGetAllJobHealth = jobHeartbeatService.getAllJobHealth as jest.Mock; +const mockGetJobHealth = jobHeartbeatService.getJobHealth as jest.Mock; +const mockFindMany = (prisma.jobHeartbeat as unknown as { findMany: jest.Mock }).findMany; +const mockUpdate = (prisma.jobHeartbeat as unknown as { update: jest.Mock }).update; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const VALID_JOB_TYPE = JobType.RECONCILIATION; // "reconciliation" +const UNKNOWN_JOB_TYPE = "not-a-real-job"; + +const NOW = new Date("2026-10-19T10:00:00.000Z"); +const NEXT = new Date("2026-10-20T10:00:00.000Z"); + +function makeHealthStatus(overrides: Partial = {}): JobHealthStatus { + return { + jobType: VALID_JOB_TYPE, + status: "healthy", + lastHeartbeat: NOW, + nextExpectedAt: NEXT, + timeSinceLastBeat: 60_000, + failureCount: 0, + isOverdue: false, + ...overrides, + }; +} + +// Raw DB row shape returned by prisma.jobHeartbeat.findMany +function makeDbRow(overrides: Record = {}) { + return { + jobType: VALID_JOB_TYPE, + status: "idle", + lastHeartbeat: NOW, + nextExpectedAt: NEXT, + failureCount: 0, + intervalMs: 86_400_000, + lastError: null, + notes: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Test app factory +// --------------------------------------------------------------------------- +function buildApp() { + const app = express(); + app.use(express.json()); + app.use("/health", createJobHealthRoutes()); + return app; +} + +// =========================================================================== +// GET /health/jobs +// =========================================================================== +describe("GET /health/jobs", () => { + afterEach(() => jest.clearAllMocks()); + + describe("happy path — all healthy", () => { + it("returns 200 when no jobs are overdue", async () => { + mockGetAllJobHealth.mockResolvedValue([makeHealthStatus()]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("healthy"); + }); + + it("response contains summary, jobs, and timestamp fields", async () => { + mockGetAllJobHealth.mockResolvedValue([makeHealthStatus()]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.body).toHaveProperty("summary"); + expect(res.body).toHaveProperty("jobs"); + expect(res.body).toHaveProperty("timestamp"); + }); + + it("summary counts are correct for all-healthy result", async () => { + mockGetAllJobHealth.mockResolvedValue([ + makeHealthStatus(), + makeHealthStatus({ jobType: JobType.WEBHOOK }), + ]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.body.summary).toMatchObject({ + total: 2, + healthy: 2, + stale: 0, + overdue: 0, + }); + }); + + it("healthy jobs are listed with required fields", async () => { + mockGetAllJobHealth.mockResolvedValue([makeHealthStatus()]); + + const res = await request(buildApp()).get("/health/jobs"); + + const job = res.body.jobs.healthy[0]; + expect(job).toHaveProperty("jobType"); + expect(job).toHaveProperty("lastHeartbeat"); + expect(job).toHaveProperty("nextExpectedAt"); + expect(job).toHaveProperty("timeSinceLastBeat"); + }); + }); + + describe("happy path — degraded (overdue jobs present)", () => { + it("returns 503 when at least one job is overdue", async () => { + mockGetAllJobHealth.mockResolvedValue([ + makeHealthStatus({ status: "failed", isOverdue: true }), + ]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.status).toBe(503); + expect(res.body.status).toBe("degraded"); + }); + + it("returns 503 when a job has isOverdue=true even if status is stale", async () => { + mockGetAllJobHealth.mockResolvedValue([ + makeHealthStatus({ status: "stale", isOverdue: true }), + ]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.status).toBe(503); + }); + + it("overdue jobs appear in the overdue array with alert field", async () => { + mockGetAllJobHealth.mockResolvedValue([ + makeHealthStatus({ status: "failed", isOverdue: true, failureCount: 3 }), + ]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.body.jobs.overdue).toHaveLength(1); + expect(res.body.jobs.overdue[0]).toHaveProperty("alert"); + expect(res.body.jobs.overdue[0].failureCount).toBe(3); + }); + + it("stale jobs (not overdue) appear in stale array with warning field", async () => { + mockGetAllJobHealth.mockResolvedValue([ + makeHealthStatus({ status: "stale", isOverdue: false }), + ]); + + const res = await request(buildApp()).get("/health/jobs"); + + // stale and not overdue → still 200 (no overdue count), stale array populated + expect(res.status).toBe(200); + expect(res.body.jobs.stale).toHaveLength(1); + expect(res.body.jobs.stale[0]).toHaveProperty("warning"); + }); + + it("returns 200 with empty jobs arrays when no heartbeats are registered", async () => { + mockGetAllJobHealth.mockResolvedValue([]); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.status).toBe(200); + expect(res.body.summary.total).toBe(0); + }); + }); + + describe("service error", () => { + it("returns 500 when the service throws", async () => { + mockGetAllJobHealth.mockRejectedValue(new Error("DB unavailable")); + + const res = await request(buildApp()).get("/health/jobs"); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Failed to retrieve job health"); + }); + }); +}); + +// =========================================================================== +// GET /health/jobs/:jobType +// =========================================================================== +describe("GET /health/jobs/:jobType", () => { + afterEach(() => jest.clearAllMocks()); + + describe("happy path — healthy job", () => { + it("returns 200 for a healthy job", async () => { + mockGetJobHealth.mockResolvedValue(makeHealthStatus()); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.status).toBe(200); + }); + + it("response body contains the health status fields", async () => { + const health = makeHealthStatus(); + mockGetJobHealth.mockResolvedValue(health); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.body).toHaveProperty("jobType"); + expect(res.body).toHaveProperty("status"); + expect(res.body).toHaveProperty("failureCount"); + expect(res.body).toHaveProperty("isOverdue"); + }); + }); + + describe("happy path — unhealthy job", () => { + it("returns 503 when the job status is not healthy", async () => { + mockGetJobHealth.mockResolvedValue( + makeHealthStatus({ status: "failed" }), + ); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.status).toBe(503); + }); + + it("returns 503 for stale status", async () => { + mockGetJobHealth.mockResolvedValue( + makeHealthStatus({ status: "stale" }), + ); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.status).toBe(503); + }); + }); + + describe("unknown jobType — 404", () => { + it("returns 404 for a jobType not in the JobType enum", async () => { + const res = await request(buildApp()).get( + `/health/jobs/${UNKNOWN_JOB_TYPE}`, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Job not found"); + expect(res.body.jobType).toBe(UNKNOWN_JOB_TYPE); + }); + + it("returns 404 when service returns null (no DB row for valid type)", async () => { + // isJobType passes but service has no record + mockGetJobHealth.mockResolvedValue(null); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Job not found"); + }); + }); + + describe("service error", () => { + it("returns 500 when the service throws", async () => { + mockGetJobHealth.mockRejectedValue(new Error("timeout")); + + const res = await request(buildApp()).get( + `/health/jobs/${VALID_JOB_TYPE}`, + ); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Failed to retrieve job health"); + }); + }); +}); + +// =========================================================================== +// GET /health/dashboard +// =========================================================================== +describe("GET /health/dashboard", () => { + afterEach(() => jest.clearAllMocks()); + + describe("happy path", () => { + it("returns 200 with analytics and timestamp", async () => { + mockFindMany.mockResolvedValue([makeDbRow()]); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("analytics"); + expect(res.body).toHaveProperty("timestamp"); + }); + + it("analytics contains totalJobs, health breakdown, and failureStats", async () => { + mockFindMany.mockResolvedValue([makeDbRow()]); + + const res = await request(buildApp()).get("/health/dashboard"); + + const { analytics } = res.body; + expect(analytics).toHaveProperty("totalJobs"); + expect(analytics).toHaveProperty("health"); + expect(analytics.health).toHaveProperty("healthy"); + expect(analytics.health).toHaveProperty("stale"); + expect(analytics.health).toHaveProperty("failed"); + expect(analytics).toHaveProperty("failureStats"); + }); + + it("jobDetails array contains one entry per DB row", async () => { + mockFindMany.mockResolvedValue([ + makeDbRow(), + makeDbRow({ jobType: JobType.WEBHOOK }), + ]); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.body.analytics.jobDetails).toHaveLength(2); + }); + + it("each jobDetail entry has required fields", async () => { + mockFindMany.mockResolvedValue([makeDbRow()]); + + const res = await request(buildApp()).get("/health/dashboard"); + + const detail = res.body.analytics.jobDetails[0]; + expect(detail).toHaveProperty("jobType"); + expect(detail).toHaveProperty("status"); + expect(detail).toHaveProperty("lastHeartbeat"); + expect(detail).toHaveProperty("nextExpectedAt"); + expect(detail).toHaveProperty("failureCount"); + expect(detail).toHaveProperty("intervalMs"); + }); + + it("returns 200 with zero totals when no heartbeats are registered", async () => { + mockFindMany.mockResolvedValue([]); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.status).toBe(200); + expect(res.body.analytics.totalJobs).toBe(0); + }); + + it("counts a job with failureCount > 0 as failed in health breakdown", async () => { + mockFindMany.mockResolvedValue([ + makeDbRow({ failureCount: 2, status: "idle" }), + ]); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.body.analytics.health.failed).toBe(1); + }); + + it("computes averageFailureCount correctly", async () => { + mockFindMany.mockResolvedValue([ + makeDbRow({ failureCount: 4 }), + makeDbRow({ jobType: JobType.WEBHOOK, failureCount: 0 }), + ]); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.body.analytics.failureStats.totalFailures).toBe(4); + expect(res.body.analytics.failureStats.averageFailureCount).toBe(2); + }); + }); + + describe("service error", () => { + it("returns 500 when prisma.jobHeartbeat.findMany throws", async () => { + mockFindMany.mockRejectedValue(new Error("connection refused")); + + const res = await request(buildApp()).get("/health/dashboard"); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Dashboard generation failed"); + }); + }); +}); + +// =========================================================================== +// POST /health/jobs/:jobType/manual-check +// =========================================================================== +describe("POST /health/jobs/:jobType/manual-check", () => { + afterEach(() => jest.clearAllMocks()); + + describe("happy path", () => { + it("returns 200 with message and health fields", async () => { + mockGetJobHealth.mockResolvedValue(makeHealthStatus()); + + const res = await request(buildApp()).post( + `/health/jobs/${VALID_JOB_TYPE}/manual-check`, + ); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Health check completed"); + expect(res.body).toHaveProperty("health"); + }); + + it("health object in response contains status and jobType", async () => { + mockGetJobHealth.mockResolvedValue(makeHealthStatus()); + + const res = await request(buildApp()).post( + `/health/jobs/${VALID_JOB_TYPE}/manual-check`, + ); + + expect(res.body.health).toHaveProperty("status"); + expect(res.body.health).toHaveProperty("jobType"); + }); + + it("calls getJobHealth with the correct jobType", async () => { + mockGetJobHealth.mockResolvedValue(makeHealthStatus()); + + await request(buildApp()).post( + `/health/jobs/${VALID_JOB_TYPE}/manual-check`, + ); + + expect(mockGetJobHealth).toHaveBeenCalledWith(VALID_JOB_TYPE); + }); + }); + + describe("unknown jobType — 404", () => { + it("returns 404 for an unrecognised jobType param", async () => { + const res = await request(buildApp()).post( + `/health/jobs/${UNKNOWN_JOB_TYPE}/manual-check`, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Job not found"); + }); + + it("returns 404 when service returns null for a valid-enum jobType", async () => { + mockGetJobHealth.mockResolvedValue(null); + + const res = await request(buildApp()).post( + `/health/jobs/${VALID_JOB_TYPE}/manual-check`, + ); + + expect(res.status).toBe(404); + }); + }); + + describe("service error", () => { + it("returns 500 when the service throws", async () => { + mockGetJobHealth.mockRejectedValue(new Error("unexpected failure")); + + const res = await request(buildApp()).post( + `/health/jobs/${VALID_JOB_TYPE}/manual-check`, + ); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Manual check failed"); + }); + }); +}); + +// =========================================================================== +// POST /health/jobs/:jobType/reset-failures +// =========================================================================== +describe("POST /health/jobs/:jobType/reset-failures", () => { + afterEach(() => jest.clearAllMocks()); + + describe("happy path", () => { + it("returns 200 with confirmation message and jobType", async () => { + mockUpdate.mockResolvedValue({}); + + const res = await request(buildApp()) + .post(`/health/jobs/${VALID_JOB_TYPE}/reset-failures`) + .send({ reason: "Manual operator reset after incident" }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Failure count reset"); + expect(res.body.jobType).toBe(VALID_JOB_TYPE); + }); + + it("calls prisma.jobHeartbeat.update with failureCount 0 and idle status", async () => { + mockUpdate.mockResolvedValue({}); + + await request(buildApp()) + .post(`/health/jobs/${VALID_JOB_TYPE}/reset-failures`) + .send({ reason: "test reset" }); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { jobType: VALID_JOB_TYPE }, + data: expect.objectContaining({ + failureCount: 0, + status: "idle", + }), + }), + ); + }); + + it("uses the provided reason in the notes field", async () => { + mockUpdate.mockResolvedValue({}); + const reason = "P1 incident resolved — resetting counter"; + + await request(buildApp()) + .post(`/health/jobs/${VALID_JOB_TYPE}/reset-failures`) + .send({ reason }); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ notes: reason }), + }), + ); + }); + + it("falls back to default notes when reason is not provided", async () => { + mockUpdate.mockResolvedValue({}); + + await request(buildApp()) + .post(`/health/jobs/${VALID_JOB_TYPE}/reset-failures`) + .send({}); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + notes: "Failure count reset by admin", + }), + }), + ); + }); + }); + + describe("unknown jobType — 404", () => { + it("returns 404 for an unrecognised jobType param", async () => { + const res = await request(buildApp()).post( + `/health/jobs/${UNKNOWN_JOB_TYPE}/reset-failures`, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Job not found"); + expect(res.body.jobType).toBe(UNKNOWN_JOB_TYPE); + }); + }); + + describe("service error", () => { + it("returns 500 when prisma.jobHeartbeat.update throws", async () => { + mockUpdate.mockRejectedValue(new Error("write conflict")); + + const res = await request(buildApp()) + .post(`/health/jobs/${VALID_JOB_TYPE}/reset-failures`) + .send({ reason: "test" }); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Reset failed"); + }); + }); +}); diff --git a/backend/src/__tests__/outbox.routes.test.ts b/backend/src/__tests__/outbox.routes.test.ts new file mode 100644 index 00000000..8c6086cc --- /dev/null +++ b/backend/src/__tests__/outbox.routes.test.ts @@ -0,0 +1,623 @@ +/** + * Route-level tests for src/routes/outbox.routes.ts + * + * Handlers under test (all mounted at /): + * GET /admin/outbox/health — consistency scan + * GET /admin/outbox/gaps — gap report (json / csv / markdown) + * GET /admin/outbox/stats — outbox queue statistics + * POST /admin/outbox/replay — replay a dead-lettered event + * + * Each handler is exercised for: + * - Happy path (correct status code + response shape) + * - Auth failure (no token / non-admin → 403 from adminMiddleware) + * - Validation failure (missing required fields → 400) + * - Service / DB error (→ 500) + * + * Strategy + * ───────── + * adminMiddleware is mocked at the module level so we can toggle it between + * "pass" (admin user attached) and "block" (403) per describe block. + * The outbox scanner functions and prisma are mocked to avoid any DB I/O. + */ + +process.env.JWT_SECRET = "test-secret-at-least-32-characters-long!!"; +process.env.JWT_ISSUER = "amana"; +process.env.JWT_AUDIENCE = "amana-api"; +process.env.DATABASE_URL = "postgresql://localhost:5432/amana_test"; +process.env.AMANA_ESCROW_CONTRACT_ID = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; +process.env.USDC_CONTRACT_ID = + "CBIELTK6YBZJU5UP2WWQEUCY7PUJE7R6OB3NIUJKL5UH4WJQJ6HVHKX"; + +import express, { Request, Response, NextFunction } from "express"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Mock adminMiddleware before the routes module is loaded +// --------------------------------------------------------------------------- +type MiddlewareFn = (req: Request, res: Response, next: NextFunction) => void; + +// Start as a passthrough (admin allowed); individual tests override as needed. +let adminMiddlewareImpl: MiddlewareFn = (_req, _res, next) => next(); + +jest.mock("../middleware/admin.middleware", () => ({ + adminMiddleware: (req: Request, res: Response, next: NextFunction) => + adminMiddlewareImpl(req, res, next), +})); + +// --------------------------------------------------------------------------- +// Mock outbox scanner functions +// --------------------------------------------------------------------------- +jest.mock("../lib/outbox/outboxScanner", () => ({ + scanOutboxCompleteness: jest.fn(), + generateOutboxGapReport: jest.fn(), + exportOutboxGaps: jest.fn(), +})); + +// --------------------------------------------------------------------------- +// Mock prisma (used lazily via require() inside stats and replay handlers) +// --------------------------------------------------------------------------- +const mockCount = jest.fn(); +const mockFindFirst = jest.fn(); +const mockUpdate = jest.fn(); + +jest.mock("../lib/db", () => ({ + prisma: { + chainEventOutbox: { + count: (...args: unknown[]) => mockCount(...args), + findFirst: (...args: unknown[]) => mockFindFirst(...args), + update: (...args: unknown[]) => mockUpdate(...args), + }, + }, +})); + +// --------------------------------------------------------------------------- +// Import after mocks are in place +// --------------------------------------------------------------------------- +import { createOutboxRoutes } from "../routes/outbox.routes"; +import { + scanOutboxCompleteness, + generateOutboxGapReport, + exportOutboxGaps, +} from "../lib/outbox/outboxScanner"; + +const mockScan = scanOutboxCompleteness as jest.Mock; +const mockGapReport = generateOutboxGapReport as jest.Mock; +const mockExport = exportOutboxGaps as jest.Mock; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const HEALTHY_REPORT = { + scanStartTime: new Date("2026-10-19T10:00:00Z"), + scanEndTime: new Date("2026-10-19T09:00:00Z"), + totalTradesScanned: 5, + gapsDetected: [], + summary: { criticalGaps: 0, warningGaps: 0, infoGaps: 0 }, +}; + +const DEGRADED_REPORT = { + ...HEALTHY_REPORT, + gapsDetected: [ + { + tradeId: "t-1", + expectedEventType: "TradeFunded", + lastStateChange: new Date(), + detectedAt: new Date(), + severity: "critical", + context: { currentStatus: "FUNDED", timeSinceChange: 400_000 }, + }, + ], + summary: { criticalGaps: 1, warningGaps: 0, infoGaps: 0 }, +}; + +const GAP_REPORT_RESULT = { + gaps: [], + summary: { totalGaps: 0, byEventType: {}, bySeverity: { critical: 0, warning: 0, info: 0 } }, + recommendations: [], +}; + +const MOCK_EVENT_ROW = { + id: 42, + tradeId: "trade-replay-001", + status: "DEAD_LETTER", + createdAt: new Date("2026-10-18T08:00:00Z"), +}; + +// --------------------------------------------------------------------------- +// App factory +// --------------------------------------------------------------------------- +function buildApp() { + const app = express(); + app.use(express.json()); + app.use("/", createOutboxRoutes()); + return app; +} + +// =========================================================================== +// GET /admin/outbox/health +// =========================================================================== +describe("GET /admin/outbox/health", () => { + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failure + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 403 when adminMiddleware blocks the request", async () => { + adminMiddlewareImpl = (_req, res) => + res.status(403).json({ code: "AUTH_ERROR", error: "Forbidden: admin access required" }); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/admin access required/i); + + // restore + adminMiddlewareImpl = (_req, _res, next) => next(); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — healthy + // ------------------------------------------------------------------------- + describe("happy path — healthy", () => { + it("returns 200 when no critical gaps are detected", async () => { + mockScan.mockResolvedValue(HEALTHY_REPORT); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("healthy"); + }); + + it("response contains status, scanTime, summary, gapCount, criticalGapsPresent", async () => { + mockScan.mockResolvedValue(HEALTHY_REPORT); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.body).toHaveProperty("status"); + expect(res.body).toHaveProperty("scanTime"); + expect(res.body).toHaveProperty("summary"); + expect(res.body).toHaveProperty("gapCount"); + expect(res.body).toHaveProperty("criticalGapsPresent"); + }); + + it("passes the timeWindow query param to scanOutboxCompleteness", async () => { + mockScan.mockResolvedValue(HEALTHY_REPORT); + + await request(buildApp()).get("/admin/outbox/health?timeWindow=120"); + + expect(mockScan).toHaveBeenCalledWith(120); + }); + + it("defaults to timeWindow=60 when param is absent", async () => { + mockScan.mockResolvedValue(HEALTHY_REPORT); + + await request(buildApp()).get("/admin/outbox/health"); + + expect(mockScan).toHaveBeenCalledWith(60); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — degraded (critical gaps present) + // ------------------------------------------------------------------------- + describe("happy path — degraded", () => { + it("returns 500 and status=degraded when critical gaps are present", async () => { + mockScan.mockResolvedValue(DEGRADED_REPORT); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.status).toBe(500); + expect(res.body.status).toBe("degraded"); + expect(res.body.criticalGapsPresent).toBe(true); + }); + + it("gapCount reflects the number of detected gaps", async () => { + mockScan.mockResolvedValue(DEGRADED_REPORT); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.body.gapCount).toBe(DEGRADED_REPORT.gapsDetected.length); + }); + }); + + // ------------------------------------------------------------------------- + // Service error + // ------------------------------------------------------------------------- + describe("service error", () => { + it("returns 500 with error message when scanner throws", async () => { + mockScan.mockRejectedValue(new Error("DB timeout")); + + const res = await request(buildApp()).get("/admin/outbox/health"); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Consistency check failed"); + }); + }); +}); + +// =========================================================================== +// GET /admin/outbox/gaps +// =========================================================================== +describe("GET /admin/outbox/gaps", () => { + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failure + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 403 when adminMiddleware blocks the request", async () => { + adminMiddlewareImpl = (_req, res) => + res.status(403).json({ code: "AUTH_ERROR", error: "Forbidden: admin access required" }); + + const res = await request(buildApp()).get("/admin/outbox/gaps"); + + expect(res.status).toBe(403); + + adminMiddlewareImpl = (_req, _res, next) => next(); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — JSON (default) + // ------------------------------------------------------------------------- + describe("happy path — JSON format", () => { + it("returns 200 with JSON report by default", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + + const res = await request(buildApp()).get("/admin/outbox/gaps"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("gaps"); + expect(res.body).toHaveProperty("summary"); + expect(res.body).toHaveProperty("recommendations"); + }); + + it("returns 200 with JSON format when format=json is explicit", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + + const res = await request(buildApp()).get("/admin/outbox/gaps?format=json"); + + expect(res.status).toBe(200); + expect(res.body.gaps).toBeDefined(); + }); + + it("calls generateOutboxGapReport with Date objects", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + + await request(buildApp()).get( + "/admin/outbox/gaps?start=2026-10-01T00:00:00Z&end=2026-10-19T00:00:00Z", + ); + + expect(mockGapReport).toHaveBeenCalledWith( + expect.any(Date), + expect.any(Date), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — CSV format + // ------------------------------------------------------------------------- + describe("happy path — CSV format", () => { + it("returns 200 with text/csv content-type for format=csv", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + mockExport.mockResolvedValue("TradeId,Severity\nt-1,critical\n"); + + const res = await request(buildApp()).get("/admin/outbox/gaps?format=csv"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + }); + + it("calls exportOutboxGaps with csv format", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + mockExport.mockResolvedValue("header\n"); + + await request(buildApp()).get("/admin/outbox/gaps?format=csv"); + + expect(mockExport).toHaveBeenCalledWith(GAP_REPORT_RESULT.gaps, "csv"); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — Markdown format + // ------------------------------------------------------------------------- + describe("happy path — Markdown format", () => { + it("returns 200 with text/markdown content-type for format=markdown", async () => { + mockGapReport.mockResolvedValue(GAP_REPORT_RESULT); + mockExport.mockResolvedValue("# Outbox Gaps\n"); + + const res = await request(buildApp()).get( + "/admin/outbox/gaps?format=markdown", + ); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/markdown/); + }); + }); + + // ------------------------------------------------------------------------- + // Service error + // ------------------------------------------------------------------------- + describe("service error", () => { + it("returns 500 when generateOutboxGapReport throws", async () => { + mockGapReport.mockRejectedValue(new Error("query failed")); + + const res = await request(buildApp()).get("/admin/outbox/gaps"); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Gap report generation failed"); + }); + }); +}); + +// =========================================================================== +// GET /admin/outbox/stats +// =========================================================================== +describe("GET /admin/outbox/stats", () => { + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failure + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 403 when adminMiddleware blocks the request", async () => { + adminMiddlewareImpl = (_req, res) => + res.status(403).json({ code: "AUTH_ERROR", error: "Forbidden: admin access required" }); + + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(res.status).toBe(403); + + adminMiddlewareImpl = (_req, _res, next) => next(); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + describe("happy path", () => { + beforeEach(() => { + // count() is called 5 times in order: total, PENDING, PROCESSED, DEAD_LETTER, RETRYING + mockCount + .mockResolvedValueOnce(100) // total + .mockResolvedValueOnce(10) // pending + .mockResolvedValueOnce(80) // processed + .mockResolvedValueOnce(5) // deadLettered + .mockResolvedValueOnce(5); // retrying + mockFindFirst.mockResolvedValue(null); // no oldest pending + }); + + it("returns 200 with outboxStats and timestamp", async () => { + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("outboxStats"); + expect(res.body).toHaveProperty("timestamp"); + }); + + it("outboxStats contains all required count fields", async () => { + const res = await request(buildApp()).get("/admin/outbox/stats"); + + const { outboxStats } = res.body; + expect(outboxStats).toHaveProperty("totalEvents"); + expect(outboxStats).toHaveProperty("pendingEvents"); + expect(outboxStats).toHaveProperty("processedEvents"); + expect(outboxStats).toHaveProperty("deadLetteredEvents"); + expect(outboxStats).toHaveProperty("retryingEvents"); + expect(outboxStats).toHaveProperty("oldestPendingAge"); + expect(outboxStats).toHaveProperty("oldestPendingId"); + }); + + it("reflects the correct counts from prisma", async () => { + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(res.body.outboxStats.totalEvents).toBe(100); + expect(res.body.outboxStats.pendingEvents).toBe(10); + expect(res.body.outboxStats.processedEvents).toBe(80); + expect(res.body.outboxStats.deadLetteredEvents).toBe(5); + expect(res.body.outboxStats.retryingEvents).toBe(5); + }); + + it("oldestPendingAge is null when there are no pending events", async () => { + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(res.body.outboxStats.oldestPendingAge).toBeNull(); + expect(res.body.outboxStats.oldestPendingId).toBeUndefined(); + }); + + it("oldestPendingAge is a number (seconds) when an oldest event exists", async () => { + mockCount + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + mockFindFirst.mockResolvedValue({ + id: 7, + createdAt: new Date(Date.now() - 90_000), // 90 seconds ago + }); + + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(typeof res.body.outboxStats.oldestPendingAge).toBe("number"); + expect(res.body.outboxStats.oldestPendingAge).toBeGreaterThanOrEqual(90); + expect(res.body.outboxStats.oldestPendingId).toBe(7); + }); + }); + + // ------------------------------------------------------------------------- + // Service error + // ------------------------------------------------------------------------- + describe("service error", () => { + it("returns 500 when prisma.count throws", async () => { + mockCount.mockRejectedValue(new Error("connection lost")); + + const res = await request(buildApp()).get("/admin/outbox/stats"); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Stats retrieval failed"); + }); + }); +}); + +// =========================================================================== +// POST /admin/outbox/replay +// =========================================================================== +describe("POST /admin/outbox/replay", () => { + afterEach(() => jest.clearAllMocks()); + + // ------------------------------------------------------------------------- + // Auth failure + // ------------------------------------------------------------------------- + describe("auth failure", () => { + it("returns 403 when adminMiddleware blocks the request", async () => { + adminMiddlewareImpl = (_req, res) => + res.status(403).json({ code: "AUTH_ERROR", error: "Forbidden: admin access required" }); + + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "1" }); + + expect(res.status).toBe(403); + + adminMiddlewareImpl = (_req, _res, next) => next(); + }); + }); + + // ------------------------------------------------------------------------- + // Validation failure + // ------------------------------------------------------------------------- + describe("validation failure", () => { + it("returns 400 when neither eventId nor tradeId is provided", async () => { + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/missing required field/i); + }); + + it("returns 400 when body has unrelated fields but no eventId or tradeId", async () => { + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ unrelated: "field" }); + + expect(res.status).toBe(400); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — by eventId + // ------------------------------------------------------------------------- + describe("happy path — replay by eventId", () => { + beforeEach(() => { + mockFindFirst.mockResolvedValue(MOCK_EVENT_ROW); + mockUpdate.mockResolvedValue({ ...MOCK_EVENT_ROW, status: "PENDING" }); + }); + + it("returns 200 with message, eventId, and tradeId", async () => { + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "42" }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Event queued for replay"); + expect(res.body.eventId).toBe(MOCK_EVENT_ROW.id); + expect(res.body.tradeId).toBe(MOCK_EVENT_ROW.tradeId); + }); + + it("calls prisma.update to reset the event to PENDING with attempts=0", async () => { + await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "42" }); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: MOCK_EVENT_ROW.id }, + data: expect.objectContaining({ + status: "PENDING", + attempts: 0, + lastError: null, + }), + }), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Happy path — by tradeId + // ------------------------------------------------------------------------- + describe("happy path — replay by tradeId", () => { + beforeEach(() => { + mockFindFirst.mockResolvedValue(MOCK_EVENT_ROW); + mockUpdate.mockResolvedValue({ ...MOCK_EVENT_ROW, status: "PENDING" }); + }); + + it("returns 200 when replaying by tradeId", async () => { + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ tradeId: "trade-replay-001" }); + + expect(res.status).toBe(200); + expect(res.body.tradeId).toBe(MOCK_EVENT_ROW.tradeId); + }); + }); + + // ------------------------------------------------------------------------- + // Not found + // ------------------------------------------------------------------------- + describe("not found", () => { + it("returns 404 when no event matches the given eventId", async () => { + mockFindFirst.mockResolvedValue(null); + + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "9999" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Event not found"); + }); + + it("returns 404 when no event matches the given tradeId", async () => { + mockFindFirst.mockResolvedValue(null); + + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ tradeId: "nonexistent-trade" }); + + expect(res.status).toBe(404); + }); + }); + + // ------------------------------------------------------------------------- + // Service error + // ------------------------------------------------------------------------- + describe("service error", () => { + it("returns 500 when prisma.findFirst throws", async () => { + mockFindFirst.mockRejectedValue(new Error("DB unreachable")); + + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "42" }); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Event replay failed"); + }); + + it("returns 500 when prisma.update throws after finding the event", async () => { + mockFindFirst.mockResolvedValue(MOCK_EVENT_ROW); + mockUpdate.mockRejectedValue(new Error("write conflict")); + + const res = await request(buildApp()) + .post("/admin/outbox/replay") + .send({ eventId: "42" }); + + expect(res.status).toBe(500); + expect(res.body.error).toBe("Event replay failed"); + }); + }); +});