feat(intake): swap a photo for a cut-out, keeping the original (#281)

Adds backend/src/intake/backgroundRemoval.ts, the shared module the drafting worker and the admin endpoints both call so a cut-out obtained either way is undoable the same way.

cutoutPathFor is pure and writes a new file beside the original rather than overwriting it, which is what keeps the original restorable and makes the JPEG-to-PNG change free. removeImageBackground only points the row at the new file after it is already on disk, and is idempotent via the original_image_path IS NOT NULL check — load-bearing twice, since it also stops a second pass from recording the cut-out as the original and losing the real one for good. restoreImageOriginal swaps the paths back and deliberately leaves the cut-out file on disk.

Extends the Task 1 integration test file with a stub sidecar bound to an ephemeral port and covers the no-op-on-repeat case plus three failure modes (500, non-image body, unreachable), asserting the row is left untouched in every failure case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:34:56 -05:00
co-authored by Claude Opus 5
parent 61e9c239d3
commit 8d12cb2f2d
3 changed files with 348 additions and 0 deletions
@@ -1,5 +1,11 @@
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 } from '../../src/intake/backgroundRemoval';
beforeEach(async () => {
await resetDb();
@@ -52,3 +58,184 @@ describe('the background-removal columns', () => {
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;
/**
* 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.
*
* 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-'));
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;
}
});
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');
});
});
@@ -0,0 +1,22 @@
import { cutoutPathFor } from '../../src/intake/backgroundRemoval';
describe('where a cut-out is written', () => {
// A new file rather than a rewrite of the original, which is what makes the
// original restorable at all — and what makes the JPEG-to-PNG change free,
// since no existing path is renamed.
it('sits beside the original with a -cutout suffix and a .png extension', () => {
expect(cutoutPathFor('/uploads/abc-123.jpg')).toBe('/uploads/abc-123-cutout.png');
});
// image_path is a contract, not a string: #103 made the stored value the
// path uploadUrl joins an origin onto.
it('keeps the /uploads/ prefix', () => {
expect(cutoutPathFor('/uploads/x.webp')).toBe('/uploads/x-cutout.png');
});
// The extension is replaced rather than appended, so a second pass cannot
// produce `.png.png`.
it('replaces the extension rather than appending to it', () => {
expect(cutoutPathFor('/uploads/x.png')).toBe('/uploads/x-cutout.png');
});
});