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