import { pool } from '../../src/db'; import { applyDraft } from '../../src/intake/applyDraft'; import { DraftOutcome } from '../../src/intake/draftListing'; import { resetDb, closeDb } from './setup/testDb'; import { draftQueued, MAX_ATTEMPTS } from '../../src/intake/draftingWorker'; import { resetAnthropicClient } from '../../src/intake/anthropicClient'; beforeEach(async () => { await resetDb(); }); afterAll(async () => { await pool.end(); await closeDb(); }); /** * An item at 'pending' with a draft row waiting, which is what a submission * leaves behind. price_cents is not set: it takes the 8000 default from #222's * migration, and that default is what the no-price case below asserts on. */ async function seedSubmission(): 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) VALUES ($1, 'a note')`, [itemId]); return itemId; } async function apply(itemId: number, outcome: DraftOutcome): Promise { const client = await pool.connect(); try { await applyDraft(client, itemId, outcome); } finally { client.release(); } } const outcome: DraftOutcome = { draft: { name: 'Blue stoneware vase', description: 'Hand-thrown, chipped base.', category: null, tags: ['blue'], suggestedPriceCents: 4500 }, model: 'claude-sonnet-5', inputTokens: 1000, outputTokens: 200, costMicros: 4000 }; describe('applying a draft', () => { it('writes the copy onto the draft row, not the item', async () => { const itemId = await seedSubmission(); await apply(itemId, outcome); const { rows } = await pool.query( `SELECT ai_name, ai_description, state, model, ai_tag_names FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(rows[0]?.ai_name).toBe('Blue stoneware vase'); expect(rows[0]?.state).toBe('ready'); expect(rows[0]?.model).toBe('claude-sonnet-5'); expect(rows[0]?.ai_tag_names).toEqual(['blue']); // The item keeps its placeholder name until a person approves the draft. // Nothing a model wrote should reach the catalogue unreviewed. const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.name).toBe('Submission placeholder'); }); // The one thing that does reach the item, because #220 chose pre-pricing over // an unpriced row — with price_source recording that nobody chose it. it('writes a suggested price onto the item and records where it came from', async () => { const itemId = await seedSubmission(); await apply(itemId, outcome); const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.price_cents).toBe(4500); const draft = await pool.query( `SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(draft.rows[0]?.price_source).toBe('ai'); expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500); }); it('leaves the default price alone when the model would not guess', async () => { const itemId = await seedSubmission(); await apply(itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } }); const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.price_cents).toBe(8000); const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]); expect(draft.rows[0]?.price_source).toBe('default'); }); it('records what the call cost', async () => { const itemId = await seedSubmission(); await apply(itemId, outcome); const { rows } = await pool.query( `SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(rows[0]?.input_tokens).toBe(1000); expect(rows[0]?.output_tokens).toBe(200); expect(rows[0]?.cost_micros).toBe(4000); expect(rows[0]?.drafted_at).not.toBeNull(); }); it('stores a category the shop actually has', async () => { const itemId = await seedSubmission(); const { rows } = await pool.query<{ id: number }>( `INSERT INTO categories (name) VALUES ('Ceramics') RETURNING id` ); await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Ceramics' } }); const draft = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); expect(draft.rows[0]?.ai_category_id).toBe(rows[0]!.id); }); // A category the shop does not have would be invisible to every storefront // filter, and the schema cannot enforce membership — so it is checked here. it('ignores a category that does not exist', async () => { const itemId = await seedSubmission(); await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Invented Category' } }); const { rows } = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]); expect(rows[0]?.ai_category_id).toBeNull(); }); // A retry after a failure has to clear the message, or a draft that // eventually succeeded still reads as broken in the review queue. it('clears a previous error when a retry succeeds', async () => { const itemId = await seedSubmission(); await pool.query( `UPDATE item_drafts SET state = 'failed', ai_error = 'timed out', attempts = 1 WHERE item_id = $1`, [itemId] ); await apply(itemId, outcome); const { rows } = await pool.query(`SELECT state, ai_error FROM item_drafts WHERE item_id = $1`, [itemId]); expect(rows[0]?.state).toBe('ready'); expect(rows[0]?.ai_error).toBeNull(); }); }); describe('the drafting worker', () => { beforeEach(() => { delete process.env.ANTHROPIC_API_KEY; resetAnthropicClient(); }); afterAll(() => { resetAnthropicClient(); }); // The case that must never lose a submission. An unconfigured environment is // a working one: the item keeps its photos and waits. it('leaves submissions queued when there is no key', async () => { const itemId = await seedSubmission(); const result = await draftQueued(); expect(result).toEqual({ drafted: 0, failed: 0, skipped: 1 }); const { rows } = await pool.query( `SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(rows[0]?.state).toBe('queued'); // Skipping is not an attempt. Otherwise a fortnight without a key would // exhaust the retries and mark everything failed with nothing wrong with it. expect(rows[0]?.attempts).toBe(0); }); it('does not touch a row that has already spent its attempts', async () => { const itemId = await seedSubmission(); await pool.query(`UPDATE item_drafts SET attempts = $2 WHERE item_id = $1`, [ itemId, MAX_ATTEMPTS ]); process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; resetAnthropicClient(); const result = await draftQueued(); expect(result.drafted).toBe(0); expect(result.failed).toBe(0); }); // A submission whose files cannot be read fails without an API call, and // without the item or its row going anywhere. it('fails a submission with no readable photos, keeping the item', async () => { const itemId = await seedSubmission(); process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; resetAnthropicClient(); const result = await draftQueued(); expect(result.failed).toBe(1); const draft = await pool.query( `SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`, [itemId] ); expect(draft.rows[0]?.attempts).toBe(1); // One try spent, two left, so it stays reachable for the sweeper. expect(draft.rows[0]?.state).toBe('queued'); expect(draft.rows[0]?.ai_error).toContain('no readable photos'); const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.status).toBe('pending'); }); // Three tries, then it stops costing money and waits for a person. The item // and its photos survive that too. it('gives up after MAX_ATTEMPTS rather than retrying forever', async () => { const itemId = await seedSubmission(); process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used'; resetAnthropicClient(); for (let i = 0; i < MAX_ATTEMPTS; i++) { await draftQueued(); } const draft = await pool.query(`SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [itemId]); expect(draft.rows[0]?.attempts).toBe(MAX_ATTEMPTS); expect(draft.rows[0]?.state).toBe('failed'); const item = await pool.query(`SELECT id, status FROM items WHERE id = $1`, [itemId]); expect(item.rows[0]?.status).toBe('pending'); // And it is not picked up again, so a dead submission stops spending money. expect(await draftQueued()).toEqual({ drafted: 0, failed: 0, skipped: 0 }); }); });