Files
bermudalambandClaude Opus 5 eae2c15f1a feat(intake): draft a listing from photos and a note (#223)
The client is a parameter rather than a module import, so every test passes a stub. A test that reaches the real API is a defect in the test: this runs on a route a stranger with a link can trigger, and each call costs money.

getAnthropicClient returns null rather than throwing when there is no key. An unconfigured environment is a working one, and the worker treats null exactly as it treats a failed call — one path rather than two.

parsed_output is guarded, not asserted. The SDK returns null there when the answer did not satisfy the schema, which is what a model replying in prose looks like; failing cleanly leaves the submission queued for a retry, where asserting would crash the worker mid-loop. Absent usage figures are treated as zero for the same reason: undercounting a cost is survivable, throwing away a draft that actually succeeded is not.

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

76 lines
2.8 KiB
TypeScript

import type Anthropic from '@anthropic-ai/sdk';
import { draftListing } from '../../src/intake/draftListing';
// getSettings reaches for the database, which a unit test has no business
// doing. The model choice is the only thing needed from it here.
jest.mock('../../src/adminSettings', () => ({
getSettings: jest.fn(async () => ({ draftingModel: 'claude-sonnet-5' }))
}));
const draft = {
name: 'Blue stoneware vase',
description: 'Hand-thrown, chipped base.',
category: null,
tags: [],
suggestedPriceCents: 4500
};
/**
* A stub, always. Every test in this file would otherwise cost money, and a
* suite that bills the shop to run is one nobody will run.
*/
function stubClient(response: unknown): Anthropic {
return {
messages: { parse: jest.fn(async () => response) }
} as unknown as Anthropic;
}
const input = { photos: [{ mediaType: 'image/jpeg', base64: 'AAAA' }], note: null, categories: [], tags: [] };
describe('draftListing', () => {
it('returns the parsed draft with what it cost', async () => {
const client = stubClient({
parsed_output: draft,
usage: { input_tokens: 1000, output_tokens: 200 }
});
const outcome = await draftListing(client, input);
expect(outcome.draft).toEqual(draft);
expect(outcome.model).toBe('claude-sonnet-5');
// 1000 * $2 + 200 * $10 per million, in micros.
expect(outcome.costMicros).toBe(4000);
});
// The SDK returns null here when the answer did not satisfy the schema — a
// model replying in prose, most likely. Failing cleanly leaves the submission
// queued for a retry; asserting on it would crash the worker mid-loop.
it('fails rather than assuming a draft came back', async () => {
const client = stubClient({ parsed_output: null, usage: { input_tokens: 10, output_tokens: 0 } });
await expect(draftListing(client, input)).rejects.toThrow(/expected shape/);
});
// Usage has been absent on some responses. Treating that as free is wrong but
// survivable; letting it throw would lose a draft that actually succeeded.
it('survives a response with no usage figures', async () => {
const client = stubClient({ parsed_output: draft });
const outcome = await draftListing(client, input);
expect(outcome.inputTokens).toBe(0);
expect(outcome.costMicros).toBe(0);
});
it('sends the photographs and the chosen model to the API', async () => {
const client = stubClient({ parsed_output: draft, usage: { input_tokens: 1, output_tokens: 1 } });
await draftListing(client, { ...input, categories: ['Ceramics'], note: 'a note' });
const params = (client.messages.parse as jest.Mock).mock.calls[0][0];
expect(params.model).toBe('claude-sonnet-5');
expect(params.system).toContain('Ceramics');
expect(params.messages[0].content.filter((b: { type: string }) => b.type === 'image')).toHaveLength(1);
});
});