WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 276 lines 8.6 kB view raw
1import { describe, it, expect, vi, afterEach } from "vitest"; 2import { Hono } from "hono"; 3import { 4 handleRouteError, 5 safeParseJsonBody, 6 getForumAgentOrError, 7} from "../route-errors.js"; 8import type { AppContext } from "../app-context.js"; 9import { createMockLogger } from "./mock-logger.js"; 10 11afterEach(() => { 12 vi.restoreAllMocks(); 13}); 14 15/** 16 * Build a one-route Hono app that calls the given handler helper. 17 * Useful for testing error-returning functions without full test context. 18 */ 19function makeApp( 20 handler: (c: any) => Response | Promise<Response> 21): Hono { 22 const app = new Hono(); 23 app.get("/test", (c) => handler(c)); 24 app.post("/test", (c) => handler(c)); 25 return app; 26} 27 28// ─── handleRouteError ───────────────────────────────────────────────────────── 29 30describe("handleRouteError", () => { 31 it("returns 503 for network errors", async () => { 32 const app = makeApp((c) => 33 handleRouteError(c, new Error("fetch failed"), "Failed to read resource", { 34 operation: "GET /test", 35 logger: createMockLogger(), 36 }) 37 ); 38 39 const res = await app.request("/test"); 40 41 expect(res.status).toBe(503); 42 const data = await res.json(); 43 expect(data.error).toBe( 44 "Unable to reach external service. Please try again later." 45 ); 46 }); 47 48 it("returns 503 for database errors", async () => { 49 const app = makeApp((c) => 50 handleRouteError(c, new Error("database query failed"), "Failed to read resource", { 51 operation: "GET /test", 52 logger: createMockLogger(), 53 }) 54 ); 55 56 const res = await app.request("/test"); 57 58 expect(res.status).toBe(503); 59 const data = await res.json(); 60 expect(data.error).toBe( 61 "Database temporarily unavailable. Please try again later." 62 ); 63 }); 64 65 it("returns 500 for unexpected errors", async () => { 66 const app = makeApp((c) => 67 handleRouteError(c, new Error("Something went wrong"), "Failed to read resource", { 68 operation: "GET /test", 69 logger: createMockLogger(), 70 }) 71 ); 72 73 const res = await app.request("/test"); 74 75 expect(res.status).toBe(500); 76 const data = await res.json(); 77 expect(data.error).toBe("Failed to read resource. Please contact support if this persists."); 78 }); 79 80 it("logs structured context on error", async () => { 81 const mockLogger = createMockLogger(); 82 const app = makeApp((c) => 83 handleRouteError(c, new Error("boom"), "Failed to fetch things", { 84 operation: "GET /test", 85 logger: mockLogger, 86 resourceId: "123", 87 }) 88 ); 89 90 await app.request("/test"); 91 92 expect(mockLogger.error).toHaveBeenCalledWith( 93 "Failed to fetch things", 94 expect.objectContaining({ 95 operation: "GET /test", 96 resourceId: "123", 97 error: "boom", 98 }) 99 ); 100 }); 101 102 it("re-throws TypeError (programming error) and logs CRITICAL", async () => { 103 const mockLogger = createMockLogger(); 104 const programmingError = new TypeError("Cannot read property of undefined"); 105 const app = makeApp((c) => 106 handleRouteError(c, programmingError, "Failed to read resource", { 107 operation: "GET /test", 108 logger: mockLogger, 109 }) 110 ); 111 112 // Hono catches re-thrown errors and returns 500 113 const res = await app.request("/test"); 114 expect(res.status).toBe(500); 115 116 // CRITICAL log must be emitted before the re-throw 117 expect(mockLogger.error).toHaveBeenCalledWith( 118 "CRITICAL: Programming error in GET /test", 119 expect.objectContaining({ 120 operation: "GET /test", 121 error: "Cannot read property of undefined", 122 stack: expect.any(String), 123 }) 124 ); 125 126 // Normal error log must NOT be emitted (re-throw bypasses it) 127 expect(mockLogger.error).not.toHaveBeenCalledWith( 128 "Failed to read resource", 129 expect.any(Object) 130 ); 131 }); 132 133 it("works for write-path errors (POST endpoints)", async () => { 134 const app = makeApp((c) => 135 handleRouteError(c, new Error("fetch failed"), "Failed to create thing", { 136 operation: "POST /test", 137 logger: createMockLogger(), 138 }) 139 ); 140 141 const res = await app.request("/test", { method: "POST" }); 142 143 expect(res.status).toBe(503); 144 const data = await res.json(); 145 expect(data.error).toBe( 146 "Unable to reach external service. Please try again later." 147 ); 148 }); 149 150 it("works for security check errors (fail closed)", async () => { 151 const app = makeApp((c) => 152 handleRouteError(c, new Error("Something unexpected"), "Unable to verify access", { 153 operation: "POST /test - security check", 154 logger: createMockLogger(), 155 }) 156 ); 157 158 const res = await app.request("/test", { method: "POST" }); 159 160 expect(res.status).toBe(500); 161 const data = await res.json(); 162 expect(data.error).toBe( 163 "Unable to verify access. Please contact support if this persists." 164 ); 165 }); 166}); 167 168// ─── safeParseJsonBody ──────────────────────────────────────────────────────── 169 170describe("safeParseJsonBody", () => { 171 it("returns parsed body on valid JSON", async () => { 172 const app = new Hono(); 173 app.post("/test", async (c) => { 174 const { body, error } = await safeParseJsonBody(c); 175 if (error) return error; 176 return c.json({ received: body }); 177 }); 178 179 const res = await app.request("/test", { 180 method: "POST", 181 headers: { "Content-Type": "application/json" }, 182 body: JSON.stringify({ text: "hello" }), 183 }); 184 185 expect(res.status).toBe(200); 186 const data = await res.json(); 187 expect(data.received).toEqual({ text: "hello" }); 188 }); 189 190 it("returns 400 error on malformed JSON", async () => { 191 const app = new Hono(); 192 app.post("/test", async (c) => { 193 const { body, error } = await safeParseJsonBody(c); 194 if (error) return error; 195 return c.json({ received: body }); 196 }); 197 198 const res = await app.request("/test", { 199 method: "POST", 200 headers: { "Content-Type": "application/json" }, 201 body: "{ invalid json }", 202 }); 203 204 expect(res.status).toBe(400); 205 const data = await res.json(); 206 expect(data.error).toBe("Invalid JSON in request body"); 207 }); 208}); 209 210// ─── getForumAgentOrError ───────────────────────────────────────────────────── 211 212describe("getForumAgentOrError", () => { 213 it("returns 500 when ForumAgent is not configured", async () => { 214 const appCtx = { 215 forumAgent: null, 216 config: { forumDid: "did:plc:forum" }, 217 logger: createMockLogger(), 218 } as unknown as AppContext; 219 220 const app = new Hono(); 221 app.get("/test", (c) => { 222 const result = getForumAgentOrError(appCtx, c, "GET /test"); 223 if (result.error) return result.error; 224 return c.json({ ok: true }); 225 }); 226 227 const res = await app.request("/test"); 228 229 expect(res.status).toBe(500); 230 const data = await res.json(); 231 expect(data.error).toContain("Forum agent not available"); 232 }); 233 234 it("returns 503 when ForumAgent is not authenticated", async () => { 235 const appCtx = { 236 forumAgent: { getAgent: () => null }, 237 config: { forumDid: "did:plc:forum" }, 238 logger: createMockLogger(), 239 } as unknown as AppContext; 240 241 const app = new Hono(); 242 app.get("/test", (c) => { 243 const result = getForumAgentOrError(appCtx, c, "GET /test"); 244 if (result.error) return result.error; 245 return c.json({ ok: true }); 246 }); 247 248 const res = await app.request("/test"); 249 250 expect(res.status).toBe(503); 251 const data = await res.json(); 252 expect(data.error).toContain("not authenticated"); 253 }); 254 255 it("returns agent when ForumAgent is configured and authenticated", async () => { 256 const mockAgent = { putRecord: vi.fn() }; 257 const appCtx = { 258 forumAgent: { getAgent: () => mockAgent }, 259 config: { forumDid: "did:plc:forum" }, 260 logger: createMockLogger(), 261 } as unknown as AppContext; 262 263 const app = new Hono(); 264 app.get("/test", (c) => { 265 const { agent, error } = getForumAgentOrError(appCtx, c, "GET /test"); 266 if (error) return error; 267 return c.json({ hasAgent: agent !== null }); 268 }); 269 270 const res = await app.request("/test"); 271 272 expect(res.status).toBe(200); 273 const data = await res.json(); 274 expect(data.hasAgent).toBe(true); 275 }); 276});