import { describe, expect, test } from 'bun:test'; import { buildHonoApp } from './hono'; import { CustomError } from './custom-errors'; /* The property: every error the Hono app renders is JSON — `{ error: string }`, or the object a CustomError's returnValue supplied. Success responses were always JSON; a text/plain failure was the one response every client had to special-case, and the one that broke `| jq`. The app under test is the REAL one: `buildHonoApp` is pure, and Hono builds its matcher lazily on the first fetch, so a throwing route added here reaches the same `onError` production traffic does. */ const appThatThrows = (error: Error) => { const app = buildHonoApp(); app.get('/boom', () => { throw error; }); return app; }; const request = (app: ReturnType) => app.request('/boom'); describe('onError renders JSON', () => { test('a CustomError with a message → { error: message }, its own status', async () => { const res = await request(appThatThrows(new CustomError(404, 'no such playlist'))); expect(res.status).toBe(404); expect(res.headers.get('content-type')).toContain('application/json'); expect(await res.json()).toEqual({ error: 'no such playlist' }); }); // An object returnValue passes through UNWRAPPED — callers read its fields (TOO_MANY_REQUESTS // carries `retryAfter`), so `{ error: {...} }` would break them. test('a CustomError with an object returnValue → that object, untouched', async () => { const res = await request(appThatThrows(new CustomError(429, 'Too many requests', { retryAfter: 30 }))); expect(res.status).toBe(429); expect(res.headers.get('content-type')).toContain('application/json'); expect(await res.json()).toEqual({ retryAfter: 30 }); }); // A string returnValue was the text/plain case before 2026-08-25; it takes the same shape as a message. test('a CustomError with a string returnValue → { error: returnValue }', async () => { const res = await request(appThatThrows(new CustomError(400, 'ignored', 'the field is required'))); expect(res.status).toBe(400); expect(res.headers.get('content-type')).toContain('application/json'); expect(await res.json()).toEqual({ error: 'the field is required' }); }); // A plain Error is a bug, not a message for the caller — the body must be JSON but must NOT leak // error.message, which nobody wrote for an outside reader. test('a plain Error → 500, { error: "Internal Server Error" }, message not leaked', async () => { const res = await request(appThatThrows(new Error('secret internal detail'))); expect(res.status).toBe(500); expect(res.headers.get('content-type')).toContain('application/json'); expect(await res.json()).toEqual({ error: 'Internal Server Error' }); }); });