diff --git a/backend/src/intake/draftSchema.ts b/backend/src/intake/draftSchema.ts new file mode 100644 index 0000000..ee3fb46 --- /dev/null +++ b/backend/src/intake/draftSchema.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +/** + * What the model must return, enforced rather than hoped for. + * + * Constraining the shape is the difference between a bad draft and a crash: + * the SDK validates the response against this before any of it reaches the + * database, so a model that answers in prose or invents a field becomes a + * caught error rather than a row full of nonsense. + * + * Every field the model may decline to answer is nullable, because it is told + * to say nothing rather than guess. A null category means it did not recognise + * one, which is a better answer than a wrong one and is exactly what the review + * queue exists to resolve. + */ + +/** Beyond anything this shop sells, so an absurd number is caught here. */ +const MAX_SUGGESTED_PRICE_CENTS = 1_000_000; + +export const DraftSchema = z.object({ + /** A short title. Until this lands, the item is named for its submission. */ + name: z.string().min(1).max(200), + /** The storefront body, rendered with html:false like every other stored body. */ + description: z.string().min(1).max(4000), + /** + * Chosen from the categories it was given, or null. The prompt supplies the + * closed set; this cannot enforce membership, so applyDraft checks the answer + * against the real table before writing anything. + */ + category: z.string().nullable(), + /** Also from a supplied set, and also checked on the way in rather than here. */ + tags: z.array(z.string()), + /** + * Cents, or null when it will not guess. Integer and bounded at both ends, + * because a fractional, negative or absurd figure reaching the review queue + * is a number somebody has to notice is wrong — and being trustworthy at a + * glance is that queue's whole job. + */ + suggestedPriceCents: z + .number() + .int() + .min(0) + .max(MAX_SUGGESTED_PRICE_CENTS) + .nullable() +}); + +export type DraftResult = z.infer; diff --git a/backend/tests/unit/draftSchema.test.ts b/backend/tests/unit/draftSchema.test.ts new file mode 100644 index 0000000..dff8cda --- /dev/null +++ b/backend/tests/unit/draftSchema.test.ts @@ -0,0 +1,54 @@ +import { DraftSchema } from '../../src/intake/draftSchema'; + +const valid = { + name: 'Blue stoneware vase', + description: 'A hand-thrown vase with a chipped base.', + category: 'Ceramics', + tags: ['stoneware', 'blue'], + suggestedPriceCents: 4500 +}; + +describe('DraftSchema', () => { + it('accepts a complete draft', () => { + expect(DraftSchema.parse(valid)).toEqual(valid); + }); + + // The model is told to say nothing rather than guess, so every field it may + // decline to answer has to be expressible as absent. + it('accepts a draft with no category, tags or price', () => { + const parsed = DraftSchema.parse({ + name: valid.name, + description: valid.description, + category: null, + tags: [], + suggestedPriceCents: null + }); + expect(parsed.category).toBeNull(); + expect(parsed.suggestedPriceCents).toBeNull(); + }); + + // A name and a description are the whole point. A draft without them is not + // a partial success worth storing. + it('refuses a draft with no name', () => { + expect(() => DraftSchema.parse({ ...valid, name: '' })).toThrow(); + }); + + it('refuses a draft with no description', () => { + expect(() => DraftSchema.parse({ ...valid, description: '' })).toThrow(); + }); + + // A negative or absurd price reaching the review queue would be a number + // somebody has to notice is wrong. Cheaper to refuse it here. + it('refuses a negative price', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: -1 })).toThrow(); + }); + + it('refuses a price beyond anything this shop sells', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 100_000_00 })).toThrow(); + }); + + // Cents, not dollars. A float would round somewhere nobody is looking. + it('refuses a fractional price', () => { + expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 45.5 })).toThrow(); + }); +});