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'; /** * 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; 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((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; } }); 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(); }); });