Files
redefined-designs/backend/tests/integration/adminItemNotFound.integration.test.ts
T
bermudalambandClaude Opus 5 d5e599a30e
Linting / lint (pull_request) Successful in 11m5s
SonarQube Analysis / sonarqube (pull_request) Successful in 38m13s
fix(admin): answer 404 for an unreadable id instead of 500 (#207)
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>
2026-09-03 16:45:51 -05:00

164 lines
5.7 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();
});
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
/**
* Nothing exists, so every id below is absent. 999999 is well-formed and
* missing; 'abc' is not a number at all. Before #207 the first answered 200
* with an empty body and the second answered 500, because the raw string
* reached Postgres and raised 22P02.
*/
const ABSENT = 999999;
describe('admin item routes for an id that does not exist', () => {
it('PUT answers 404 rather than 200 with an empty body', async () => {
const res = await request(app)
.put(`/api/admin/items/${ABSENT}`)
.field('name', 'renamed')
.field('price', '10.00');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not found' });
});
it('mark-sold answers 404', async () => {
const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-sold`);
expect(res.status).toBe(404);
});
it('mark-available answers 404', async () => {
const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-available`);
expect(res.status).toBe(404);
});
// A garbage id used to reach Postgres and raise 22P02, which the catch turned
// into a 500. From the caller's side "/items/abc" identifies no item, exactly
// like "/items/999999" does.
it.each(['abc', '1.5', '-1', ''])('PUT answers 404 for the id %p', async (id) => {
const res = await request(app)
.put(`/api/admin/items/${id}`)
.field('name', 'renamed')
.field('price', '10.00');
expect(res.status).toBe(404);
});
it('mark-available answers 404 for a non-numeric id', async () => {
const res = await request(app).post('/api/admin/items/abc/mark-available');
expect(res.status).toBe(404);
});
});
describe('admin item routes for an id that does exist', () => {
async function makeItem(): Promise<number> {
const res = await request(app)
.post('/api/admin/items')
.field('name', 'a real item')
.field('price', '12.00')
.attach('images', PNG, 'a.png');
return res.body.id;
}
// The point of the change is to stop lying about misses, not to start
// refusing hits.
it('PUT still updates and answers with the item', async () => {
const id = await makeItem();
const res = await request(app)
.put(`/api/admin/items/${id}`)
.field('name', 'renamed')
.field('price', '15.00');
expect(res.status).toBe(200);
expect(res.body.name).toBe('renamed');
expect(res.body.price_cents).toBe(1500);
});
it('mark-available still publishes', async () => {
const id = await makeItem();
const res = await request(app).post(`/api/admin/items/${id}/mark-available`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('available');
});
});
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'],
['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);
});
});