import request from 'supertest'; import app from '../../src/app'; import { pool } from '../../src/db'; import { resetDb, closeDb } from './setup/testDb'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); /** * The message the failing query rejects with. Deliberately shaped like a real * Postgres error and deliberately containing both words the leak test looks * for, so that assertion is checking something rather than passing because the * words happened not to appear. */ const DB_FAILURE = 'invalid input syntax for type integer while reading items'; /** * Fails the next database call, whichever route makes it. * * This used to be done by asking `/api/items/not-a-number` and relying on that * route sending an unparseable id to Postgres. That worked, but it tied this * test to one route declining to validate its input: fixing that route — which * #307 wanted, and #207 had already done everywhere else — would have left this * test green while removing the thing it tests. * * A rejected query is the failure the error middleware actually exists for, and * it does not depend on any route being wrong. `pool` is a real object, so this * spy is not sensitive to how the module is transpiled. */ function failNextQuery() { return jest.spyOn(pool, 'query').mockRejectedValueOnce(new Error(DB_FAILURE)); } describe('unexpected route failures', () => { // Express 4 does not forward a rejected async handler on its own. Without the // asyncRoute wrapper plus the error middleware this request never gets a // response at all — and a hung request renders as an empty storefront rather // than a visible failure, which is exactly how the 2026-08-17 incident // presented. it('answers with 500 instead of leaving the request hanging', async () => { const spy = failNextQuery(); try { const res = await request(app).get('/api/filters'); expect(res.status).toBe(500); expect(res.body.error).toBe('internal error'); } finally { spy.mockRestore(); } }); it('does not leak the underlying database error to the client', async () => { const spy = failNextQuery(); try { const res = await request(app).get('/api/filters'); const body = JSON.stringify(res.body); expect(body).not.toContain('syntax'); expect(body).not.toContain('items'); // The whole message, not only the words above, so a future error format // cannot slip through by wording it differently. expect(body).not.toContain(DB_FAILURE); } finally { spy.mockRestore(); } }); // The route that used to be this file's trigger. It answers 404 now rather // than 500, because an id that cannot be read identifies nothing — asserted // here so the behaviour that replaced the trigger is itself covered. it('answers 404, not 500, for an id that cannot be read', async () => { const res = await request(app).get('/api/items/not-a-number'); expect(res.status).toBe(404); expect(res.body.error).toBe('not found'); }); // These parse to positive integers and used to fetch real rows — /items/5.0 // answered with item 5 (#307). Never a crash, which is why it went unnoticed. it.each(['5.0', '1e2', '0x10'])('answers 404 for %p rather than fetching a row', async (id) => { const res = await request(app).get(`/api/items/${id}`); expect(res.status).toBe(404); }); });