ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 439 lines 13 kB view raw
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});