fix(backend): route every async handler through the error middleware (#59)

Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout.

Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it.

Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively.

No behaviour changes on the success path; the failure path turns a hung request into a logged 500.

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:12:25 -05:00
co-authored by Claude Opus 5
parent 07247caab4
commit 1d7aba2d60
9 changed files with 226 additions and 63 deletions
+10 -10
View File
@@ -140,7 +140,7 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
res.json(rows);
}));
router.post('/items', uploadImages, async (req: Request, res: Response) => {
router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const categoryId = readCategoryId(req.body.category_id);
@@ -180,9 +180,9 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const categoryId = readCategoryId(req.body.category_id);
@@ -233,7 +233,7 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
} finally {
client.release();
}
});
}));
router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
const itemId = Number(req.params.id);
@@ -251,12 +251,12 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
res.status(204).end();
}));
router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => {
router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: Response) => {
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
res.status(204).end();
});
}));
router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
[req.params.id]
@@ -265,15 +265,15 @@ router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
// customer, so everyone watching it hears about it.
await notifyFavoritersOfSale([Number(req.params.id)], null);
res.json(rows[0]);
});
}));
router.post('/items/:id/mark-available', async (req: Request, res: Response) => {
router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
WHERE id=$1 RETURNING *`,
[req.params.id]
);
res.json(rows[0]);
});
}));
export default router;