The governing rule is that a submission is the only irreplaceable thing in this pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, an unreadable file, a failed call and three exhausted retries all end the same way: the item keeps its photos, stays pending, and waits. Nothing in this file deletes anything. An absent key returns early and spends no attempt. Counting it as a failure would mean a fortnight without a key exhausted the retries and marked every waiting submission failed, with nothing wrong with any of them. A failure leaves the row queued while tries remain, so the sweeper picks it up again, and failed once they are spent, so a dead submission stops costing money and waits for a person instead of retrying forever. Photos are read once and passed down rather than loaded again inside the drafting call — the first read already has to happen to check there is at least one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.9 KiB
TypeScript
245 lines
8.9 KiB
TypeScript
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<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) VALUES ($1, 'a note')`, [itemId]);
|
|
return itemId;
|
|
}
|
|
|
|
async function apply(itemId: number, outcome: DraftOutcome): Promise<void> {
|
|
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 });
|
|
});
|
|
});
|