58 lines
2.8 KiB
TypeScript
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' });
|
|
});
|
|
});
|