ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1/**
2 * Error Handler Middleware Tests
3 * Tests for error handler middleware
4 */
5
6import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7import { Hono, Context } from 'hono';
8import { ZodError, z } from 'zod';
9import { errorHandler } from './error';
10import {
11 ApiError,
12 AuthenticationError,
13 ValidationError,
14 NotFoundError,
15 DatabaseError,
16} from '../errors';
17
18describe('Error Handler Middleware', () => {
19 let app: Hono;
20 let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
21
22 beforeEach(() => {
23 app = new Hono();
24 app.onError(errorHandler);
25
26 // Spy on console.error to verify logging
27 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
28 });
29
30 afterEach(() => {
31 consoleErrorSpy.mockRestore();
32 });
33
34 describe('Validation Errors', () => {
35 it('returns 400 for ValidationError', async () => {
36 app.get('/test', () => {
37 throw new ValidationError('Invalid input data');
38 });
39
40 const res = await app.request('/test');
41
42 expect(res.status).toBe(400);
43 const body = await res.json() as { success: boolean; error: string };
44 expect(body.success).toBe(false);
45 expect(body.error).toBe('Invalid input data');
46 });
47
48 it('returns 400 for ZodError with formatted message', async () => {
49 app.get('/test', () => {
50 const schema = z.object({
51 email: z.string().email(),
52 age: z.number().min(18),
53 });
54
55 // This will throw ZodError
56 schema.parse({ email: 'invalid', age: 10 });
57 return null as never; // Unreachable
58 });
59
60 const res = await app.request('/test');
61
62 expect(res.status).toBe(400);
63 const body = await res.json() as { success: boolean; error: string; details: unknown[] };
64 expect(body.success).toBe(false);
65 expect(body.error).toBeDefined();
66 expect(body.details).toBeDefined();
67 });
68
69 it('includes Zod error details', async () => {
70 app.get('/test', () => {
71 const schema = z.object({
72 username: z.string().min(3),
73 });
74
75 schema.parse({ username: 'ab' });
76 return null as never; // Unreachable
77 });
78
79 const res = await app.request('/test');
80
81 expect(res.status).toBe(400);
82 const body = await res.json() as { details: unknown[] };
83 expect(body.details).toBeDefined();
84 expect(Array.isArray(body.details)).toBe(true);
85 });
86 });
87
88 describe('Authentication Errors', () => {
89 it('returns 401 for AuthenticationError', async () => {
90 app.get('/test', () => {
91 throw new AuthenticationError('Invalid session');
92 });
93
94 const res = await app.request('/test');
95
96 expect(res.status).toBe(401);
97 const body = await res.json() as { success: boolean; error: string };
98 expect(body.success).toBe(false);
99 expect(body.error).toContain('session has expired');
100 });
101
102 it('provides user-friendly message for authentication errors', async () => {
103 app.get('/test', () => {
104 throw new AuthenticationError('Technical auth error');
105 });
106
107 const res = await app.request('/test');
108
109 const body = await res.json() as { error: string };
110 // Should not expose technical details
111 expect(body.error).not.toContain('Technical');
112 expect(body.error).toContain('log in again');
113 });
114 });
115
116 describe('Not Found Errors', () => {
117 it('returns 404 for NotFoundError', async () => {
118 app.get('/test', () => {
119 throw new NotFoundError('Resource not found');
120 });
121
122 const res = await app.request('/test');
123
124 expect(res.status).toBe(404);
125 const body = await res.json() as { success: boolean; error: string };
126 expect(body.success).toBe(false);
127 expect(body.error).toBe('Resource not found');
128 });
129 });
130
131 describe('Database Errors', () => {
132 it('returns 500 for DatabaseError', async () => {
133 app.get('/test', () => {
134 throw new DatabaseError('Connection failed');
135 });
136
137 const res = await app.request('/test');
138
139 expect(res.status).toBe(500);
140 const body = await res.json() as { success: boolean; error: string };
141 expect(body.success).toBe(false);
142 expect(body.error).toContain('Database operation failed');
143 });
144
145 it('provides generic message for database errors', async () => {
146 app.get('/test', () => {
147 throw new DatabaseError('SELECT * FROM users WHERE id = $1 -- sensitive query');
148 });
149
150 const res = await app.request('/test');
151
152 const body = await res.json() as { error: string };
153 // Should not expose database details
154 expect(body.error).not.toContain('SELECT');
155 expect(body.error).toContain('try again later');
156 });
157 });
158
159 describe('API Errors', () => {
160 it('returns correct status code for ApiError', async () => {
161 app.get('/test', () => {
162 throw new ApiError('Bad Request', 400);
163 });
164
165 const res = await app.request('/test');
166
167 expect(res.status).toBe(400);
168 const body = await res.json() as { success: boolean; error: string };
169 expect(body.success).toBe(false);
170 expect(body.error).toBe('Bad Request');
171 });
172
173 it('includes details when provided', async () => {
174 app.get('/test', () => {
175 const details = JSON.stringify({ fields: ['email', 'username'] });
176 throw new ApiError('Validation failed', 400, details);
177 });
178
179 const res = await app.request('/test');
180
181 const body = await res.json() as { details: string };
182 expect(body.details).toBeDefined();
183 const parsedDetails = JSON.parse(body.details);
184 expect(parsedDetails.fields).toEqual(['email', 'username']);
185 });
186
187 it('defaults to 500 for invalid status codes', async () => {
188 app.get('/test', () => {
189 throw new ApiError('Error', 999); // Invalid status code
190 });
191
192 const res = await app.request('/test');
193
194 expect(res.status).toBe(500);
195 });
196
197 it('handles various valid status codes', async () => {
198 const statusCodes = [400, 401, 403, 404, 500, 503];
199
200 for (const code of statusCodes) {
201 const testApp = new Hono();
202 testApp.onError(errorHandler);
203 testApp.get('/test', () => {
204 throw new ApiError(`Error ${code}`, code);
205 });
206
207 const res = await testApp.request('/test');
208 expect(res.status).toBe(code);
209 }
210 });
211 });
212
213 describe('Generic Errors', () => {
214 it('returns 500 for generic Error', async () => {
215 app.get('/test', () => {
216 throw new Error('Something went wrong');
217 });
218
219 const res = await app.request('/test');
220
221 expect(res.status).toBe(500);
222 const body = await res.json() as { success: boolean; error: string };
223 expect(body.success).toBe(false);
224 expect(body.error).toContain('try again later');
225 });
226
227 it('provides generic message for unknown errors', async () => {
228 app.get('/test', () => {
229 throw new Error('Internal server error with sensitive data');
230 });
231
232 const res = await app.request('/test');
233
234 const body = await res.json() as { error: string };
235 // Should not expose error details
236 expect(body.error).not.toContain('sensitive data');
237 });
238
239 it('handles errors without message', async () => {
240 app.get('/test', () => {
241 throw new Error();
242 });
243
244 const res = await app.request('/test');
245
246 expect(res.status).toBe(500);
247 const body = await res.json() as { success: boolean; error: string };
248 expect(body.success).toBe(false);
249 expect(body.error).toBeDefined();
250 });
251 });
252
253 describe('Error Logging', () => {
254 it('logs error details', async () => {
255 app.get('/test', () => {
256 throw new Error('Test error');
257 });
258
259 await app.request('/test');
260
261 expect(consoleErrorSpy).toHaveBeenCalled();
262 const logCall = consoleErrorSpy.mock.calls[0];
263 expect(logCall[0]).toBe('[ERROR]');
264 });
265
266 it('includes request metadata in logs', async () => {
267 app.get('/test-path', () => {
268 throw new Error('Test error');
269 });
270
271 await app.request('/test-path');
272
273 const logCall = consoleErrorSpy.mock.calls[0];
274 const logData = logCall[1] as Record<string, unknown>;
275
276 expect(logData).toHaveProperty('timestamp');
277 expect(logData).toHaveProperty('path');
278 expect(logData.path).toBe('/test-path');
279 expect(logData).toHaveProperty('method');
280 expect(logData.method).toBe('GET');
281 });
282
283 it('includes user DID when authenticated', async () => {
284 app.use('*', async (c, next) => {
285 (c.set as (key: string, value: unknown) => void)('did', 'did:plc:test123');
286 await next();
287 });
288
289 app.get('/test', () => {
290 throw new Error('Test error');
291 });
292
293 await app.request('/test');
294
295 const logCall = consoleErrorSpy.mock.calls[0];
296 const logData = logCall[1] as Record<string, unknown>;
297
298 expect(logData.userDid).toBe('did:plc:test123');
299 });
300
301 it('logs "unauthenticated" when no user DID', async () => {
302 app.get('/test', () => {
303 throw new Error('Test error');
304 });
305
306 await app.request('/test');
307
308 const logCall = consoleErrorSpy.mock.calls[0];
309 const logData = logCall[1] as Record<string, unknown>;
310
311 expect(logData.userDid).toBe('unauthenticated');
312 });
313
314 it('includes error stack trace', async () => {
315 app.get('/test', () => {
316 throw new Error('Test error');
317 });
318
319 await app.request('/test');
320
321 const logCall = consoleErrorSpy.mock.calls[0];
322 const logData = logCall[1] as Record<string, unknown>;
323
324 expect(logData.stack).toBeDefined();
325 expect(typeof logData.stack).toBe('string');
326 });
327 });
328
329 describe('Error Response Format', () => {
330 it('always includes success: false', async () => {
331 app.get('/validation', () => {
332 throw new ValidationError('Test');
333 });
334 app.get('/auth', () => {
335 throw new AuthenticationError('Test');
336 });
337 app.get('/generic', () => {
338 throw new Error('Test');
339 });
340
341 const endpoints = ['/validation', '/auth', '/generic'];
342
343 for (const endpoint of endpoints) {
344 const res = await app.request(endpoint);
345 const body = await res.json() as { success: boolean };
346 expect(body.success).toBe(false);
347 }
348 });
349
350 it('always includes error message', async () => {
351 app.get('/test', () => {
352 throw new Error('Test error');
353 });
354
355 const res = await app.request('/test');
356 const body = await res.json() as { error: string };
357
358 expect(body.error).toBeDefined();
359 expect(typeof body.error).toBe('string');
360 expect(body.error.length).toBeGreaterThan(0);
361 });
362
363 it('returns valid JSON even for malformed errors', async () => {
364 app.get('/test', () => {
365 const err = new Error('Test');
366 // @ts-expect-error - Testing malformed error
367 err.statusCode = 'not-a-number';
368 throw err;
369 });
370
371 const res = await app.request('/test');
372
373 expect(res.headers.get('content-type')).toContain('application/json');
374 const body = await res.json() as Record<string, unknown>;
375 expect(body).toBeDefined();
376 });
377 });
378
379 describe('Edge Cases', () => {
380 it('handles null errors', async () => {
381 app.get('/test', () => {
382 // Testing null throw - Hono propagates non-Error values as-is
383 throw null; // eslint-disable-line @typescript-eslint/no-throw-literal
384 });
385
386 // Hono doesn't catch non-Error throws, so request() rejects
387 try {
388 await app.request('/test');
389 expect.fail('should have thrown');
390 } catch (e) {
391 expect(e).toBeNull();
392 }
393 });
394
395 it('handles errors thrown during error handling', async () => {
396 // Meta-test: what if error handler itself throws?
397 const badErrorHandler = (err: Error, c: Context) => {
398 throw new Error('Error handler error');
399 };
400
401 const testApp = new Hono();
402 testApp.onError(badErrorHandler);
403 testApp.get('/test', () => {
404 throw new Error('Original error');
405 });
406
407 // Hono propagates error handler failures, so request() rejects
408 try {
409 await testApp.request('/test');
410 expect.fail('should have thrown');
411 } catch (e) {
412 expect(e).toBeInstanceOf(Error);
413 expect((e as Error).message).toBe('Error handler error');
414 }
415 });
416
417 it('handles circular references in error details', async () => {
418 app.get('/test', () => {
419 // Try to create a circular JSON string (will throw, which is expected)
420 try {
421 const circular: Record<string, unknown> = {};
422 circular.self = circular;
423 JSON.stringify(circular); // This will throw
424 } catch {
425 // If stringify fails, pass a string instead
426 throw new ApiError('Circular error', 500, 'details-with-circular-ref');
427 }
428 return null as never; // Unreachable
429 });
430
431 const res = await app.request('/test');
432
433 expect(res.status).toBe(500);
434 // Should not throw during JSON serialization
435 const body = await res.json() as Record<string, unknown>;
436 expect(body).toBeDefined();
437 });
438 });
439});