Files
redefined-designs/backend/tests/integration/backgroundRemoval.integration.test.ts
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

418 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from 'http';
import { AddressInfo } from 'net';
import { promises as fsp } from 'fs';
import os from 'os';
import path from 'path';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import {
removeImageBackground,
restoreImageOriginal,
removeBackgroundsForItem,
restoreOriginalsForItem
} from '../../src/intake/backgroundRemoval';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/** An item with a draft row and one image, which is what a submission leaves. */
async function seedSubmission(
imagePath = '/uploads/photo.jpg'
): Promise<{ itemId: number; imageId: number }> {
const item = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
);
const itemId = item.rows[0]!.id;
await pool.query(`INSERT INTO item_drafts (item_id) VALUES ($1)`, [itemId]);
const image = await pool.query<{ id: number }>(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, 0) RETURNING id`,
[itemId, imagePath]
);
return { itemId, imageId: image.rows[0]!.id };
}
describe('the background-removal columns', () => {
// Default true because the submitter's checkbox is ticked by default, and
// because a row written by any path that does not mention the column should
// behave like the new default rather than needing a backfill.
it('defaults remove_background to true', async () => {
const { itemId } = await seedSubmission();
const { rows } = await pool.query<{ remove_background: boolean }>(
`SELECT remove_background FROM item_drafts WHERE item_id = $1`,
[itemId]
);
expect(rows[0]?.remove_background).toBe(true);
});
// Null is also the answer to "can this be restored?", which is why there is
// no separate flag: one fact, one place.
it('leaves original_image_path null until a photo has been cut out', async () => {
const { imageId } = await seedSubmission();
const { rows } = await pool.query<{ original_image_path: string | null }>(
`SELECT original_image_path FROM item_images WHERE id = $1`,
[imageId]
);
expect(rows[0]?.original_image_path).toBeNull();
});
});
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]);
interface ImagePaths {
image_path: string;
original_image_path: string | null;
}
let uploads = '';
let stub: http.Server | null = null;
let previousUploadsDir: string | undefined;
/**
* A real uploads directory and a stub sidecar.
*
* A temporary directory rather than the configured one, because these tests
* write files and a suite that leaves rubbish in a developer's uploads volume
* is a suite people stop running. `afterEach` removes it and puts back
* whatever UPLOADS_DIR held before, so this suite does not leak either the
* directory or the environment variable into whatever runs after it.
*
* No test here contacts the real sidecar. It takes forty seconds to start, and
* a suite that depends 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(), 'bgremoval-'));
previousUploadsDir = process.env.UPLOADS_DIR;
process.env.UPLOADS_DIR = uploads;
stub = http.createServer((req, res) => {
// Drain the body before answering, or the client sees a reset rather than
// the status this test is about.
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);
}
afterEach(async () => {
delete process.env.REMBG_URL;
if (stub) {
await new Promise<void>((resolve) => stub!.close(() => resolve()));
stub = null;
}
if (uploads !== '') {
await fsp.rm(uploads, { recursive: true, force: true });
uploads = '';
}
if (previousUploadsDir === undefined) delete process.env.UPLOADS_DIR;
else process.env.UPLOADS_DIR = previousUploadsDir;
previousUploadsDir = undefined;
});
async function seedWithFile(): Promise<{ itemId: number; imageId: number; name: string }> {
const name = 'original.jpg';
const seeded = await seedSubmission(`/uploads/${name}`);
await fsp.writeFile(path.join(uploads, name), JPEG_BYTES);
return { ...seeded, name };
}
async function pathsOf(imageId: number): Promise<ImagePaths> {
const { rows } = await pool.query<ImagePaths>(
`SELECT image_path, original_image_path FROM item_images WHERE id = $1`,
[imageId]
);
return rows[0]!;
}
describe('removing one photos background', () => {
it('points the row at the cut-out and records where the original went', async () => {
await startStub(answerWithPng);
const { imageId } = await seedWithFile();
await removeImageBackground(imageId);
const paths = await pathsOf(imageId);
expect(paths.image_path).toBe('/uploads/original-cutout.png');
expect(paths.original_image_path).toBe('/uploads/original.jpg');
});
// The original is the only copy of an item that may no longer be in the
// sender's hands. Nothing in this module is allowed to remove it.
it('leaves the original file on disk', async () => {
await startStub(answerWithPng);
const { imageId, name } = await seedWithFile();
await removeImageBackground(imageId);
await expect(fsp.access(path.join(uploads, name))).resolves.toBeUndefined();
});
it('writes the cut-out where the row now says it is', async () => {
await startStub(answerWithPng);
const { imageId } = await seedWithFile();
await removeImageBackground(imageId);
await expect(fsp.readFile(path.join(uploads, 'original-cutout.png'))).resolves.toEqual(
PNG_BYTES
);
});
// The guard that stops a second pass recording the cut-out as the original
// and losing the real one for good.
it('is a no-op on a photo that has already been cut out', async () => {
await startStub(answerWithPng);
const { imageId } = await seedWithFile();
await removeImageBackground(imageId);
await removeImageBackground(imageId);
expect((await pathsOf(imageId)).original_image_path).toBe('/uploads/original.jpg');
});
});
describe('when the sidecar will not answer', () => {
it('leaves the row untouched on a 500', async () => {
await startStub((_req, res) => {
res.writeHead(500);
res.end('boom');
});
const { imageId } = await seedWithFile();
await expect(removeImageBackground(imageId)).rejects.toThrow(/500/);
const paths = await pathsOf(imageId);
expect(paths.image_path).toBe('/uploads/original.jpg');
expect(paths.original_image_path).toBeNull();
});
it('leaves the row untouched when it returns something that is not an image', async () => {
await startStub((_req, res) => {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end('<html>gateway error</html>');
});
const { imageId } = await seedWithFile();
await expect(removeImageBackground(imageId)).rejects.toThrow(/not a PNG/);
const paths = await pathsOf(imageId);
expect(paths.image_path).toBe('/uploads/original.jpg');
expect(paths.original_image_path).toBeNull();
});
it('leaves the row untouched when it is unreachable', async () => {
await startStub(answerWithPng);
// Closed before the call, so the connection is refused rather than hung.
// The URL stays set, which is the case worth modelling: configured, and
// not there.
await new Promise<void>((resolve) => stub!.close(() => resolve()));
stub = null;
const { imageId } = await seedWithFile();
await expect(removeImageBackground(imageId)).rejects.toThrow();
const paths = await pathsOf(imageId);
expect(paths.image_path).toBe('/uploads/original.jpg');
expect(paths.original_image_path).toBeNull();
});
});
describe('putting the original back', () => {
it('swaps the paths back and clears the record', async () => {
await startStub(answerWithPng);
const { imageId } = await seedWithFile();
await removeImageBackground(imageId);
await restoreImageOriginal(imageId);
const paths = await pathsOf(imageId);
expect(paths.image_path).toBe('/uploads/original.jpg');
expect(paths.original_image_path).toBeNull();
});
it('refuses a photo that was never cut out, rather than blanking its path', async () => {
await startStub(answerWithPng);
const { imageId } = await seedWithFile();
await expect(restoreImageOriginal(imageId)).rejects.toThrow(/no original/);
expect((await pathsOf(imageId)).image_path).toBe('/uploads/original.jpg');
});
});
describe('acting on every photo of one item', () => {
/** One item with three photos on disk, which is what an upload leaves. */
async function seedItemWithPhotos(): Promise<{ itemId: number; imageIds: number[] }> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Three views', 'available') RETURNING id`
);
const itemId = rows[0]!.id;
const imageIds: number[] = [];
for (const [index, name] of ['front.jpg', 'back.jpg', 'base.jpg'].entries()) {
const image = await pool.query<{ id: number }>(
`INSERT INTO item_images (item_id, image_path, sort_order)
VALUES ($1, $2, $3) RETURNING id`,
[itemId, `/uploads/${name}`, index]
);
imageIds.push(image.rows[0]!.id);
await fsp.writeFile(path.join(uploads, name), JPEG_BYTES);
}
return { itemId, imageIds };
}
it('cuts out every photo and says how many', async () => {
await startStub(answerWithPng);
const { itemId } = await seedItemWithPhotos();
const summary = await removeBackgroundsForItem(itemId);
expect(summary).toEqual({ total: 3, removed: 3, failed: false });
});
// An item with no photos is not a failure. It is a perfectly ordinary item
// somebody has not photographed yet, and the button should say so rather
// than erroring.
it('reports nothing to do for an item with no photos', async () => {
await startStub(answerWithPng);
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Unphotographed', 'pending') RETURNING id`
);
expect(await removeBackgroundsForItem(rows[0]!.id)).toEqual({
total: 0,
removed: 0,
failed: false
});
});
// The case the whole summary exists for: the admin needs to know how far it
// got, because the answer decides whether pressing it again is worth it.
it('stops at the first failure and reports how far it got', async () => {
let served = 0;
await startStub((_req, res) => {
served += 1;
// The first photo works; the sidecar dies before the second.
if (served > 1) {
res.writeHead(500);
res.end('boom');
return;
}
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG_BYTES);
});
const { itemId } = await seedItemWithPhotos();
const summary = await removeBackgroundsForItem(itemId);
expect(summary).toEqual({ total: 3, removed: 1, failed: true });
});
// And the reason stopping early is acceptable: a retry resumes rather than
// starting over, because removeImageBackground skips what it already did.
it('a retry finishes the job without re-cutting what worked', async () => {
let served = 0;
await startStub((_req, res) => {
served += 1;
if (served === 2) {
res.writeHead(500);
res.end('boom');
return;
}
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG_BYTES);
});
const { itemId } = await seedItemWithPhotos();
const first = await removeBackgroundsForItem(itemId);
expect(first.failed).toBe(true);
const second = await removeBackgroundsForItem(itemId);
expect(second).toEqual({ total: 3, removed: 3, failed: false });
});
// Unconfigured is not a failure anywhere else in this feature and is not one
// here. Nothing was attempted, so nothing went wrong.
it('does nothing, and calls it nothing, when there is no sidecar', async () => {
await startStub(answerWithPng);
const { itemId } = await seedItemWithPhotos();
delete process.env.REMBG_URL;
expect(await removeBackgroundsForItem(itemId)).toEqual({
total: 3,
removed: 0,
failed: false
});
});
});
describe('putting every original back', () => {
it('restores each photo that was cut out', async () => {
await startStub(answerWithPng);
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Two views', 'available') RETURNING id`
);
const itemId = rows[0]!.id;
for (const [index, name] of ['a.jpg', 'b.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);
}
await removeBackgroundsForItem(itemId);
const summary = await restoreOriginalsForItem(itemId);
expect(summary).toEqual({ total: 2, restored: 2, failed: false });
const { rows: after } = await pool.query<{ image_path: string }>(
`SELECT image_path FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
[itemId]
);
expect(after.map((r) => r.image_path)).toEqual(['/uploads/a.jpg', '/uploads/b.jpg']);
});
// A photo that was never cut out is skipped rather than being an error — the
// mixed state a partial failure leaves behind has to be restorable too.
it('skips photos that were never cut out', async () => {
await startStub(answerWithPng);
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Mixed', 'available') RETURNING id`
);
const itemId = rows[0]!.id;
for (const [index, name] of ['x.jpg', 'y.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);
}
// Cut out only the first, leaving the second as it arrived.
const { rows: images } = await pool.query<{ id: number }>(
`SELECT id FROM item_images WHERE item_id = $1 ORDER BY sort_order`,
[itemId]
);
await removeImageBackground(images[0]!.id);
expect(await restoreOriginalsForItem(itemId)).toEqual({ total: 2, restored: 1, failed: false });
});
});