Files
redefined-designs/backend/tests/integration/adminItemBackgrounds.integration.test.ts
T
bermudalambandClaude Opus 5 445c9c4a22 fix(backgrounds): report a partial restore instead of throwing (#293)
restoreOriginalsForItem rethrew anything that was not NoOriginalToRestoreError, which handed asyncRoute a bare 500 and discarded how far the restore had already got. That breaks the invariant the feature is built on: photos restored before the failure really are back, and an admin standing in front of the modal needs the count to decide whether pressing the button again is worth anything. RestoreSummary now carries `failed` and the loop stops and reports, exactly the shape and the reasoning removeBackgroundsForItem already had.

The restore-originals route gains the missing 404 for an item that does not exist — remove-backgrounds always had it, and the two handlers are copy-paste rather than a shared helper, so nothing would have caught them diverging. draftingWorker's .catch is now only reachable if the image-listing query itself throws, since removeBackgroundsForItem no longer rejects over a single photo; its comment says so rather than describing behaviour that has moved.

The `failed` branch is covered by a unit test that stubs the database module in its own module registry. It cannot honestly be an integration test: the only failure the function can report is a database fault, and the only way to inject one into a real run is to interfere with the single pool every integration suite in the --runInBand process shares and that afterAll calls pool.end() on. Two tests that did exactly that are removed here — they left the suite reporting a failure against its own afterAll and leaking a handle that stopped it exiting. Nor is the fault reachable through data alone: the swap's WHERE original_image_path IS NOT NULL guarantees the value it writes into the NOT NULL image_path, and item_images carries no unique, check or foreign-key constraint on either column, so no row can be seeded that makes the statement fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:02:23 -05:00

203 lines
7.3 KiB
TypeScript

import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
let uploads = '';
let previousUploadsDir: string | undefined;
let stub: http.Server | null = null;
/**
* A stub sidecar on an ephemeral port, and a temporary uploads directory.
*
* Port 0 rather than a fixed number: several ports in the 55000s are
* Hyper-V-reserved on this machine. No test here contacts a real rembg — it
* takes forty seconds to start, and a suite depending on that is broken by
* construction.
*/
async function startStub(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): Promise<void> {
uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'itembg-'));
previousUploadsDir = process.env.UPLOADS_DIR;
process.env.UPLOADS_DIR = uploads;
stub = http.createServer((req, res) => {
req.on('data', () => undefined);
req.on('end', () => handler(req, res));
});
await new Promise<void>((resolve) => stub!.listen(0, '127.0.0.1', resolve));
process.env.REMBG_URL = `http://127.0.0.1:${(stub!.address() as AddressInfo).port}`;
}
function answerWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG_BYTES);
}
beforeEach(async () => {
await resetDb();
});
afterEach(async () => {
delete process.env.REMBG_URL;
if (previousUploadsDir === undefined) delete process.env.UPLOADS_DIR;
else process.env.UPLOADS_DIR = previousUploadsDir;
if (uploads) await fsp.rm(uploads, { recursive: true, force: true });
uploads = '';
if (stub) {
await new Promise<void>((resolve) => stub!.close(() => resolve()));
stub = null;
}
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/** An available item with two photos on disk. */
async function seedItem(): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Blue vase', 'available') RETURNING id`
);
const itemId = rows[0]!.id;
for (const [index, name] of ['front.jpg', 'back.jpg'].entries()) {
await pool.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
[itemId, `/uploads/${name}`, index]
);
await fsp.writeFile(path.join(uploads, name), JPEG_BYTES);
}
return itemId;
}
describe('removing every background on an item', () => {
it('answers with the summary', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, removed: 2, failed: false });
});
// Every status, with no carve-out. A sold item's photos are still the shop's
// photos, and improving them changes nothing about the sale.
it.each(['sold', 'reserved', 'pending'])('works on a %s item', async (status) => {
await startStub(answerWithPng);
const itemId = await seedItem();
await pool.query(`UPDATE items SET status = $2 WHERE id = $1`, [itemId, status]);
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body.removed).toBe(2);
});
// The departure from #281's per-photo endpoints, and the reason for it: this
// acts on several images, so "did it work" has no single answer and a 502
// would throw away the count that makes the outcome actionable.
it('answers 200 with the count when the sidecar fails, not 502', async () => {
await startStub((_req, res) => {
res.writeHead(500);
res.end('boom');
});
const itemId = await seedItem();
const res = await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, removed: 0, failed: true });
});
it('answers 404 for an id that cannot be read', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/abc/remove-backgrounds')).status).toBe(404);
});
it('answers 404 for an item that does not exist', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/999999/remove-backgrounds')).status).toBe(404);
});
});
describe('restoring every original on an item', () => {
it('puts them back and says how many', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
const res = await request(app).post(`/api/admin/items/${itemId}/restore-originals`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ total: 2, restored: 2, failed: false });
});
it('answers 404 for an id that cannot be read', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/abc/restore-originals')).status).toBe(404);
});
// remove-backgrounds has always had this case; restore-originals did not,
// and the two handlers are copy-paste rather than a shared helper — nothing
// would have caught them diverging.
it('answers 404 for an item that does not exist', async () => {
await startStub(answerWithPng);
expect((await request(app).post('/api/admin/items/999999/restore-originals')).status).toBe(404);
});
});
// Extra scope beyond the endpoints themselves: Task 3 derives its button label
// from `original_image_path`, which the admin item select did not carry before
// this change. Asserted here because it is this task's select that changed.
describe('what item images carry in each list', () => {
it('gives original_image_path to admin, not the storefront', async () => {
await startStub(answerWithPng);
const itemId = await seedItem();
await request(app).post(`/api/admin/items/${itemId}/remove-backgrounds`);
const adminRes = await request(app).get('/api/admin/items');
expect(adminRes.status).toBe(200);
const adminItem = adminRes.body.find((item: { id: number }) => item.id === itemId);
expect(adminItem.images[0]).toHaveProperty('original_image_path', '/uploads/front.jpg');
const publicRes = await request(app).get('/api/items');
expect(publicRes.status).toBe(200);
const publicItem = publicRes.body.find((item: { id: number }) => item.id === itemId);
expect(publicItem.images[0]).not.toHaveProperty('original_image_path');
});
});
describe('what the admin screen is told about the feature', () => {
it('says it is on when a sidecar is configured', async () => {
process.env.REMBG_URL = 'http://rembg-syn:7000';
const res = await request(app).get('/api/admin/config');
expect(res.status).toBe(200);
expect(res.body.backgroundRemoval).toBe(true);
});
it('says it is off when none is', async () => {
delete process.env.REMBG_URL;
const res = await request(app).get('/api/admin/config');
expect(res.body.backgroundRemoval).toBe(false);
});
});