Files
redefined-designs/backend/tests/integration/backgroundRemoval.integration.test.ts
T
bermudalambandClaude Opus 5 6f8a0db130
Linting / lint (pull_request) Successful in 2m43s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m48s
test(integration): clean up background-removal test-harness leftovers (#281)
backgroundRemoval.integration.test.ts exported seedSubmission for no reason — nothing imports it, since draftingBackgroundRemoval.integration.test.ts and adminItemDrafts.integration.test.ts each wrote their own seeding helpers. Dropped the export, kept the function for local use.

All three of these suites create a temporary uploads directory with mkdtemp and point UPLOADS_DIR at it, but none of them removed the directory afterward or restored the previous UPLOADS_DIR value — checked and the leak existed in all three, not just the one the review flagged. Each afterEach now removes its temp directory with fs.rm and restores (or deletes) UPLOADS_DIR to what it held before the test touched it, so this suite no longer leaves rubbish in the OS temp directory or a stale environment variable for whatever runs after it in the same process.

This is test scaffolding cleanup, not a feature change — no runtime path in the application deletes anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:43:25 -05:00

253 lines
8.8 KiB
TypeScript
Raw 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 } 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');
});
});