import { describe, it, expect, vi, afterEach } from "vitest"; import { Hono } from "hono"; import { handleRouteError, safeParseJsonBody, getForumAgentOrError, } from "../route-errors.js"; import type { AppContext } from "../app-context.js"; import { createMockLogger } from "./mock-logger.js"; afterEach(() => { vi.restoreAllMocks(); }); /** * Build a one-route Hono app that calls the given handler helper. * Useful for testing error-returning functions without full test context. */ function makeApp( handler: (c: any) => Response | Promise ): Hono { const app = new Hono(); app.get("/test", (c) => handler(c)); app.post("/test", (c) => handler(c)); return app; } // ─── handleRouteError ───────────────────────────────────────────────────────── describe("handleRouteError", () => { it("returns 503 for network errors", async () => { const app = makeApp((c) => handleRouteError(c, new Error("fetch failed"), "Failed to read resource", { operation: "GET /test", logger: createMockLogger(), }) ); const res = await app.request("/test"); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe( "Unable to reach external service. Please try again later." ); }); it("returns 503 for database errors", async () => { const app = makeApp((c) => handleRouteError(c, new Error("database query failed"), "Failed to read resource", { operation: "GET /test", logger: createMockLogger(), }) ); const res = await app.request("/test"); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe( "Database temporarily unavailable. Please try again later." ); }); it("returns 500 for unexpected errors", async () => { const app = makeApp((c) => handleRouteError(c, new Error("Something went wrong"), "Failed to read resource", { operation: "GET /test", logger: createMockLogger(), }) ); const res = await app.request("/test"); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toBe("Failed to read resource. Please contact support if this persists."); }); it("logs structured context on error", async () => { const mockLogger = createMockLogger(); const app = makeApp((c) => handleRouteError(c, new Error("boom"), "Failed to fetch things", { operation: "GET /test", logger: mockLogger, resourceId: "123", }) ); await app.request("/test"); expect(mockLogger.error).toHaveBeenCalledWith( "Failed to fetch things", expect.objectContaining({ operation: "GET /test", resourceId: "123", error: "boom", }) ); }); it("re-throws TypeError (programming error) and logs CRITICAL", async () => { const mockLogger = createMockLogger(); const programmingError = new TypeError("Cannot read property of undefined"); const app = makeApp((c) => handleRouteError(c, programmingError, "Failed to read resource", { operation: "GET /test", logger: mockLogger, }) ); // Hono catches re-thrown errors and returns 500 const res = await app.request("/test"); expect(res.status).toBe(500); // CRITICAL log must be emitted before the re-throw expect(mockLogger.error).toHaveBeenCalledWith( "CRITICAL: Programming error in GET /test", expect.objectContaining({ operation: "GET /test", error: "Cannot read property of undefined", stack: expect.any(String), }) ); // Normal error log must NOT be emitted (re-throw bypasses it) expect(mockLogger.error).not.toHaveBeenCalledWith( "Failed to read resource", expect.any(Object) ); }); it("works for write-path errors (POST endpoints)", async () => { const app = makeApp((c) => handleRouteError(c, new Error("fetch failed"), "Failed to create thing", { operation: "POST /test", logger: createMockLogger(), }) ); const res = await app.request("/test", { method: "POST" }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe( "Unable to reach external service. Please try again later." ); }); it("works for security check errors (fail closed)", async () => { const app = makeApp((c) => handleRouteError(c, new Error("Something unexpected"), "Unable to verify access", { operation: "POST /test - security check", logger: createMockLogger(), }) ); const res = await app.request("/test", { method: "POST" }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toBe( "Unable to verify access. Please contact support if this persists." ); }); }); // ─── safeParseJsonBody ──────────────────────────────────────────────────────── describe("safeParseJsonBody", () => { it("returns parsed body on valid JSON", async () => { const app = new Hono(); app.post("/test", async (c) => { const { body, error } = await safeParseJsonBody(c); if (error) return error; return c.json({ received: body }); }); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "hello" }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.received).toEqual({ text: "hello" }); }); it("returns 400 error on malformed JSON", async () => { const app = new Hono(); app.post("/test", async (c) => { const { body, error } = await safeParseJsonBody(c); if (error) return error; return c.json({ received: body }); }); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{ invalid json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toBe("Invalid JSON in request body"); }); }); // ─── getForumAgentOrError ───────────────────────────────────────────────────── describe("getForumAgentOrError", () => { it("returns 500 when ForumAgent is not configured", async () => { const appCtx = { forumAgent: null, config: { forumDid: "did:plc:forum" }, logger: createMockLogger(), } as unknown as AppContext; const app = new Hono(); app.get("/test", (c) => { const result = getForumAgentOrError(appCtx, c, "GET /test"); if (result.error) return result.error; return c.json({ ok: true }); }); const res = await app.request("/test"); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent is not authenticated", async () => { const appCtx = { forumAgent: { getAgent: () => null }, config: { forumDid: "did:plc:forum" }, logger: createMockLogger(), } as unknown as AppContext; const app = new Hono(); app.get("/test", (c) => { const result = getForumAgentOrError(appCtx, c, "GET /test"); if (result.error) return result.error; return c.json({ ok: true }); }); const res = await app.request("/test"); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("not authenticated"); }); it("returns agent when ForumAgent is configured and authenticated", async () => { const mockAgent = { putRecord: vi.fn() }; const appCtx = { forumAgent: { getAgent: () => mockAgent }, config: { forumDid: "did:plc:forum" }, logger: createMockLogger(), } as unknown as AppContext; const app = new Hono(); app.get("/test", (c) => { const { agent, error } = getForumAgentOrError(appCtx, c, "GET /test"); if (error) return error; return c.json({ hasAgent: agent !== null }); }); const res = await app.request("/test"); expect(res.status).toBe(200); const data = await res.json(); expect(data.hasAgent).toBe(true); }); });