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