fix(admin): answer 404 for an unreadable id instead of 500 (#207)
Linting / lint (pull_request) Successful in 11m5s
SonarQube Analysis / sonarqube (pull_request) Successful in 38m13s

The issue asked for two things and only one had been done. A well-formed but absent id already answered 404 — the PUT route carries a comment saying so. A malformed one still reached Postgres as text, raised 22P02 on an integer column, and surfaced through the route's catch as a 500, telling the admin the server had broken when the truth is that no such item can exist. That half is now closed everywhere rather than on the three routes that happened to have it.

Guarded: DELETE an item, DELETE an image, unpublish, and every route in adminItemDrafts — publish, regenerate, discard, restore, and the two background-removal endpoints added by #281. The last of those were flagged in that feature's own final review as sharing this pre-existing shape, so they are fixed with the rest rather than left to be found again.

Routes carrying two ids guard both. A route can guard the first and forget the second, and the forgotten one fails exactly as loudly, so there is a case each way for both image endpoints and for DELETE image.

DELETE deliberately still answers 204 for a well-formed id that is absent. The method is idempotent and the caller's intent, that the item should not exist, is satisfied either way; what must not happen is a 500. There is a test pinning that so the distinction is a decision rather than an omission.

Also replaced the raw req.params.id and Number(req.params.id) uses that sat inside routes which had already computed a validated id. They were safe, because the guard above them made them safe, but a validated id and a raw one side by side in the same handler is how this bug comes back.

The test block named "a non-numeric id on every admin item route" covered two routes. It now covers every route that takes an id, which is what makes its name true.

Closes #207

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 16:45:51 -05:00
co-authored by Claude Opus 5
parent fe1ae64752
commit d5e599a30e
3 changed files with 123 additions and 22 deletions
@@ -101,10 +101,63 @@ describe('admin item routes for an id that does exist', () => {
describe('a non-numeric id on every admin item route', () => {
// Each of these used to reach Postgres, raise 22P02 and surface as a 500.
// The block was named "every" while covering two; it now covers every POST
// route that takes an id, which is what makes the name true (#207).
it.each([
['mark-sold', '/api/admin/items/abc/mark-sold'],
['mark-available', '/api/admin/items/abc/mark-available']
['mark-available', '/api/admin/items/abc/mark-available'],
['unpublish', '/api/admin/items/abc/unpublish'],
['regenerate', '/api/admin/item-drafts/abc/regenerate'],
['discard', '/api/admin/item-drafts/abc/discard'],
['restore', '/api/admin/item-drafts/abc/restore'],
['remove-background', '/api/admin/item-drafts/abc/images/1/remove-background'],
['restore-original', '/api/admin/item-drafts/abc/images/1/restore-original'],
// The image id, not the item id — a route with two of them can guard one
// and forget the other, and only a case each way would notice.
['remove-background by image', '/api/admin/item-drafts/1/images/abc/remove-background'],
['restore-original by image', '/api/admin/item-drafts/1/images/abc/restore-original']
])('%s answers 404', async (_name, path) => {
expect((await request(app).post(path)).status).toBe(404);
});
it('PUT answers 404', async () => {
const res = await request(app)
.put('/api/admin/items/abc')
.field('name', 'renamed')
.field('price', '10.00');
expect(res.status).toBe(404);
});
// Publish validates its body before it looks at the id, so this sends a
// valid one — otherwise the 400 would mask whether the id was ever handled.
it('publish answers 404', async () => {
const res = await request(app)
.post('/api/admin/item-drafts/abc/publish')
.send({ name: 'a name', description: '', priceCents: 1000 });
expect(res.status).toBe(404);
});
it.each([
['DELETE an item', '/api/admin/items/abc'],
['DELETE an image by item id', '/api/admin/items/abc/images/1'],
['DELETE an image by image id', '/api/admin/items/1/images/abc']
])('%s answers 404', async (_name, path) => {
expect((await request(app).delete(path)).status).toBe(404);
});
});
/**
* A well-formed id that is simply absent.
*
* Deliberately not the same question as a malformed one. DELETE stays 204 for
* an item that is not there: the method is idempotent and the caller's intent —
* that the item should not exist — is satisfied either way. What must not
* happen is a 500, which is what a malformed id used to produce.
*/
describe('a well-formed but absent id on the delete routes', () => {
it('DELETE answers 204 rather than failing', async () => {
expect((await request(app).delete(`/api/admin/items/${ABSENT}`)).status).toBe(204);
});
});