Files
redefined-designs/backend/tests/integration/errorHandling.integration.test.ts
T
synAdminandClaude Opus 5 5bba28bfda
Linting / lint (pull_request) Successful in 3m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m3s
fix(tests): the error-handling suite would not compile, and nothing local said so (#307)
The trigger added in #322 does not typecheck. pg declares query with several overloads and jest.spyOn resolves the mock argument against the last of them, whose parameter list is empty, so the inferred type of a rejection value is never and an Error cannot be assigned to it. ts-jest compiles each suite as it runs, so this surfaced as a suite that failed to run rather than as a test that failed — which is why CI reported 446 tests passing, zero failing, and the step red anyway.

The cast is the type system rather than a shortcut, and says so in the file: every overload rejects on failure, and the cast only chooses which one to check against.

The reason this reached main is the part worth keeping. The backend has a tsconfig.test.json covering scripts and tests, and nothing was running it — build compiles src alone, and I had been reporting tsc clean on that basis while touching test files it never looked at. The frontend build has run its equivalent all along, so the gap was one workspace wide and invisible from the other.

It is now a script, typecheck:tests, and it reproduces this failure in seconds against no database. That matters beyond this fix: the standing excuse for integration regressions here has been that the suite needs Postgres and a Node this machine cannot run, and a type error in a test was never actually in that category — it only looked like it because the check that finds it was never invoked.

Not added to build. The Dockerfile runs that, and a production image should not fail to build because a test file has a type error.

Verified: typecheck:tests clean, build tsc clean, lint 0 errors with no new warnings, 485 unit tests passing. The suite itself still needs CI to run.

Refs #307

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:17:34 -05:00

98 lines
3.8 KiB
TypeScript

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() {
// `as never` is the type system, not a shortcut. `pg` declares `query` with
// several overloads, and `jest.spyOn` resolves the mock's argument against
// the last of them, whose parameter list is empty — so the inferred type for
// a rejection value is `never` and nothing can be assigned to it. Rejecting
// is what all of the overloads do on failure; the cast only says which one to
// check against.
return jest.spyOn(pool, 'query').mockRejectedValueOnce(new Error(DB_FAILURE) as never);
}
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);
});
});