Files
redefined-designs/backend/tests/integration/drafting.integration.test.ts
T
bermudalambandClaude Opus 5 511022d248 feat(intake): record a draft without publishing it (#223)
The copy goes on item_drafts, never on the item. The item keeps its placeholder name and description until a person approves them in the review queue (#225) — nothing a model wrote reaches the catalogue unreviewed.

The price is the deliberate exception, because #220 chose to price an item on arrival rather than leave it unpriced. price_source records that the number came from a model rather than a person, so the review queue can show it as unconfirmed. With no suggestion the item keeps the migration's 8000 default and price_source stays 'default'; the queue shows both the same way, as a number nobody has chosen yet.

A category is checked against the real table before it is stored. The schema constrains the shape of the answer but cannot enforce membership, and a category the shop does not have would be invisible to every storefront filter — a draft nobody could find, rather than an obvious error.

A successful retry clears ai_error, or a draft that eventually worked would still read as broken in the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00

152 lines
5.6 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';
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();
});
});