Files
redefined-designs/backend/tests/integration/draftingBackgroundRemoval.integration.test.ts
T
bermudalambandClaude Opus 5 b06eac4640 fix(intake): clear remove_background when an admin restores a photo (#281)
item_drafts.remove_background is written once at intake and never updated afterward. Restoring a photo clears original_image_path, which is exactly what makes the row look "never cut out" to removeImageBackground — so a submitter's ticked checkbox, followed by the worker cutting the photo out, followed by an admin restoring a poor result, followed by a click on Regenerate, would silently re-cut the same photo the admin had just put back. Nothing was lost, but the control the design calls "what makes a poor result survivable" was quietly defeated by the button sitting next to it.

restoreImageOriginal now swaps the image's paths back and clears item_drafts.remove_background for that item in one transaction, so a restore that succeeds while the flag update fails cannot reintroduce the bug. An admin restoring any photo on an item is treated as overriding the submitter's original request for the whole item — the flag is per-item while the swap is per-photo, so there is no narrower place to record the decision, and turning off the whole item's auto-removal is the conservative direction: the alternative is re-cutting something a person deliberately undid.

Added an integration test in draftingBackgroundRemoval.integration.test.ts that drafts a submission with the intent set, cuts it out, restores it, mirrors what the admin's Regenerate button does (state back to queued, attempts cleared), runs the worker again, and asserts the photo is still not cut out.

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

190 lines
6.8 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 { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { draftQueued } from '../../src/intake/draftingWorker';
import { resetAnthropicClient } from '../../src/intake/anthropicClient';
import { draftListing } from '../../src/intake/draftListing';
import { restoreImageOriginal } from '../../src/intake/backgroundRemoval';
/**
* The worker's background-removal step (#281).
*
* The model is mocked rather than reached. What is under test is what the
* worker does *after* a draft is written — which of the two paths it takes,
* and what survives when the sidecar does not answer — and none of that
* depends on what the model said.
*/
jest.mock('../../src/intake/draftListing');
const draftListingMock = draftListing as jest.MockedFunction<typeof draftListing>;
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
let uploads = '';
let stub: http.Server | null = null;
beforeEach(async () => {
await resetDb();
draftListingMock.mockResolvedValue({
draft: {
name: 'Blue stoneware vase',
description: 'Hand-thrown, chipped base.',
category: null,
tags: [],
suggestedPriceCents: 4500
},
model: 'claude-sonnet-5',
inputTokens: 1000,
outputTokens: 200,
costMicros: 4000
});
// Non-empty is all that is needed: getAnthropicClient only has to return
// something other than null, and the mock above is what answers.
process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used';
resetAnthropicClient();
uploads = await fsp.mkdtemp(path.join(os.tmpdir(), 'workerbg-'));
process.env.UPLOADS_DIR = uploads;
stub = http.createServer((req, res) => {
req.on('data', () => undefined);
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(PNG_BYTES);
});
});
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}`;
});
afterEach(async () => {
delete process.env.REMBG_URL;
delete process.env.ANTHROPIC_API_KEY;
resetAnthropicClient();
if (stub) {
await new Promise<void>((resolve) => stub!.close(() => resolve()));
stub = null;
}
});
afterAll(async () => {
await pool.end();
await closeDb();
});
/** A queued submission with one real file on disk and the intent set. */
async function seedSubmissionWithPhoto(options: { removeBackground: boolean }): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
);
const itemId = rows[0]!.id;
await pool.query(
`INSERT INTO item_drafts (item_id, submitter_note, remove_background)
VALUES ($1, 'a note', $2)`,
[itemId, options.removeBackground]
);
await pool.query(
`INSERT INTO item_images (item_id, image_path, sort_order)
VALUES ($1, '/uploads/worker.jpg', 0)`,
[itemId]
);
await fsp.writeFile(path.join(uploads, 'worker.jpg'), JPEG_BYTES);
return itemId;
}
async function originalPathOf(itemId: number): Promise<string | null> {
const { rows } = await pool.query<{ original_image_path: string | null }>(
`SELECT original_image_path FROM item_images WHERE item_id = $1`,
[itemId]
);
return rows[0]?.original_image_path ?? null;
}
describe('background removal after a draft', () => {
// The mock has to actually be in play, or the two cases below would both
// pass for the wrong reason — a draft that never happened cuts nothing out.
it('drafts successfully, which is what the removal step follows', async () => {
await seedSubmissionWithPhoto({ removeBackground: false });
expect(await draftQueued(1)).toEqual({ drafted: 1, failed: 0, skipped: 0 });
});
// Recorded at submission and acted on here, so the sender never waits and a
// sidecar that is down cannot fail their upload.
it('cuts out the photos when the submitter asked for it', async () => {
const itemId = await seedSubmissionWithPhoto({ removeBackground: true });
await draftQueued(1);
expect(await originalPathOf(itemId)).toBe('/uploads/worker.jpg');
});
it('leaves the photos alone when they did not', async () => {
const itemId = await seedSubmissionWithPhoto({ removeBackground: false });
await draftQueued(1);
expect(await originalPathOf(itemId)).toBeNull();
});
// The governing rule: removal is a convenience on top of a draft that was
// written correctly. A sidecar failure must never turn a good draft into a
// failed one, because the queue is what the admin actually works from.
it('leaves the draft ready when the sidecar fails', async () => {
const itemId = await seedSubmissionWithPhoto({ removeBackground: true });
// Configured, and nothing listening on it.
process.env.REMBG_URL = 'http://127.0.0.1:1';
await draftQueued(1);
const { rows } = await pool.query<{ state: string }>(
`SELECT state FROM item_drafts WHERE item_id = $1`,
[itemId]
);
expect(rows[0]?.state).toBe('ready');
expect(await originalPathOf(itemId)).toBeNull();
});
// The bug this closes: remove_background is written once at intake and
// otherwise never updated, so a restored photo used to look identical to
// one that was simply never cut out — and Regenerate would read the same
// stale `true` and cut it out again, quietly undoing what the admin just
// did. restoreImageOriginal now clears the flag as part of the restore
// itself, so the worker respects the admin's decision on the next pass.
it('does not re-cut a photo the admin restored, even after Regenerate', async () => {
const itemId = await seedSubmissionWithPhoto({ removeBackground: true });
await draftQueued(1);
expect(await originalPathOf(itemId)).toBe('/uploads/worker.jpg');
const { rows: imageRows } = await pool.query<{ id: number }>(
`SELECT id FROM item_images WHERE item_id = $1`,
[itemId]
);
await restoreImageOriginal(imageRows[0]!.id);
const { rows: flagRows } = await pool.query<{ remove_background: boolean }>(
`SELECT remove_background FROM item_drafts WHERE item_id = $1`,
[itemId]
);
expect(flagRows[0]?.remove_background).toBe(false);
// Mirrors what the admin's Regenerate button does: back to queued, with
// attempts cleared, so the worker picks the item up again.
await pool.query(
`UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
[itemId]
);
await draftQueued(1);
expect(await originalPathOf(itemId)).toBeNull();
});
});