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; 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; let previousUploadsDir: string | undefined; 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-')); previousUploadsDir = process.env.UPLOADS_DIR; 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((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((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; }); 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 { 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 { 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(); }); });