Files
platform/src/servers/hono-error-shape.test.ts
T

58 lines
2.8 KiB
TypeScript

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<typeof appThatThrows>) => 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' });
});
});