import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { classifyInput, confabulationReason, GROCERY_PATTERN, merchantKey, merchantMatches, platformForSource, PROMPT_PLACEHOLDER_MERCHANTS, quoteShared, reconcileItems, resolveMerchantKind, textCouldBeReceipt, toLineItems, validateHeader, type ExtractedOrder, } from "../../lib/intake-rules"; import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT, ExtractionError, liteLLMExtractor, ORDER_ITEM_PROMPT } from "../../lib/intake-extract"; import { askCard, candidatesCard, enrichedCard, type Block } from "../../lib/intake-blocks"; /** * The intake router's pure rules, against the strings real receipts print. * The two PDFs are the user's own Uber Eats exports (plan §3): a Coles grocery * run and a Burgertory meal whose burgers are named after people. */ const fixtures = resolve(__dirname, "../fixtures/intake"); const pdf = (name: string) => readFileSync(resolve(fixtures, name)); describe("classifyInput — input type picks the extractor", () => { it("PDF by MIME or by name", () => { expect(classifyInput({ type: "application/pdf", name: "x" })).toBe("pdf"); expect(classifyInput({ type: "application/octet-stream", name: "Receipt_18Aug2026.pdf" })).toBe("pdf"); }); it("images and text", () => { expect(classifyInput({ type: "image/png", name: "IMG_1.png" })).toBe("image"); expect(classifyInput({ type: "", name: "photo.HEIC" })).toBe("image"); expect(classifyInput({ type: "text/plain", name: "" })).toBe("text"); }); it("declines what it cannot read", () => { expect(classifyInput({ type: "video/mp4", name: "clip.mp4" })).toBe("unsupported"); expect(classifyInput({ type: "application/zip", name: "a.zip" })).toBe("unsupported"); }); }); describe("platformForSource", () => { it("maps the tap to expense_metadata.platform vocabulary", () => { expect(platformForSource("uber_eats")).toBe("ubereats"); expect(platformForSource("doordash")).toBe("doordash"); expect(platformForSource("coles")).toBeNull(); expect(platformForSource("other")).toBeNull(); }); }); describe("merchant pattern — grocery | food | ask", () => { it("the pattern list is a substring match on the normalised name", () => { expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery"); expect(resolveMerchantKind("other", "Woolworths Group Limited")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "WOOLIES METRO")).toBe("grocery"); expect(resolveMerchantKind("doordash", "ALDI Werribee")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "IGA Xpress")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "FoodWorks Manor Lakes")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "Costco Wholesale")).toBe("grocery"); }); it("a delivery order that is not a grocer is a meal", () => { expect(resolveMerchantKind("uber_eats", "Burgertory (Werribee)")).toBe("food"); expect(resolveMerchantKind("doordash", "Mad Mex")).toBe("food"); }); it("an empty or unknown merchant asks", () => { expect(resolveMerchantKind("uber_eats", null)).toBe("ask"); expect(resolveMerchantKind("uber_eats", " ")).toBe("ask"); expect(resolveMerchantKind("doordash", "Unknown")).toBe("ask"); }); it("the Coles/Woolworths taps are ground truth", () => { expect(resolveMerchantKind("coles", "Romeo's Food Hall")).toBe("grocery"); expect(resolveMerchantKind("woolworths", null)).toBe("grocery"); }); it("`other` with an unrecognised merchant asks; a remembered answer sticks", () => { expect(resolveMerchantKind("other", "Romeo's Food Hall")).toBe("ask"); expect(resolveMerchantKind("other", "Romeo's Food Hall", "grocery")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "Romeo's Food Hall", "grocery")).toBe("grocery"); }); it("merchantKey folds case and punctuation; merchantMatches allows containment only", () => { expect(merchantKey("Coles (Wyndham Vale)")).toBe("coles wyndham vale"); expect(merchantMatches("Coles (Wyndham Vale)", "coles wyndham vale")).toBe(true); expect(merchantMatches("Coles (Wyndham Vale)", "Coles")).toBe(true); expect(merchantMatches("Order - Coles (Wyndham Vale)", "Coles (Wyndham Vale)")).toBe(true); expect(merchantMatches("Burgertory (Werribee)", "Coles")).toBe(false); expect(merchantMatches(null, "Coles")).toBe(false); }); it("whole words, not substrings: `Bigals Pizza` contains `iga` and is neither a grocer nor an IGA", () => { expect(GROCERY_PATTERN.test(merchantKey("Bigals Pizza"))).toBe(false); expect(resolveMerchantKind("uber_eats", "Bigals Pizza")).toBe("food"); expect(merchantMatches("Bigals Pizza", "IGA")).toBe(false); // and the grocers a boundary must not cost us expect(resolveMerchantKind("uber_eats", "IGA Wyndham Vale")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "Woolworths Group Limited")).toBe("grocery"); expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery"); expect(merchantMatches("IGA Wyndham Vale", "IGA")).toBe(true); }); }); describe("validateHeader — nothing is written from a half-read upload", () => { const base: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, items: [] }; it("accepts a complete header and rounds the total", () => { const r = validateHeader({ ...base, total: 22.404 }); expect(r.ok && r.header.total).toBe(22.4); }); it("missing total", () => { const r = validateHeader({ ...base, total: null }); expect(r.ok).toBe(false); expect(!r.ok && r.missing).toEqual(["total"]); }); it("missing or malformed date", () => { expect(validateHeader({ ...base, order_date: null }).ok).toBe(false); expect(validateHeader({ ...base, order_date: "19/8/26" }).ok).toBe(false); }); it("a missing merchant is NOT a missing header — it becomes an ask", () => { const r = validateHeader({ ...base, merchant: null }); expect(r.ok && r.header.merchant).toBeNull(); }); }); describe("reconcileItems — the missing-screenshot check", () => { it("reconciles to the printed item subtotal, not the total: fees and promotions are not items", () => { // The real Coles export: 15 lines summing $118.87 against a printed $118.94 subtotal and an $85.74 total. const r = reconcileItems({ merchant: "Coles", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, items: [{ quantity: 1, description: "a", line_total: 118.87 }], }); expect(r.ok).toBe(true); expect(r.basis).toBe("subtotal"); }); it("falls back to the total when no subtotal is printed", () => { const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }, { quantity: 1, description: "Joel", line_total: 14 }] }); expect(r.ok).toBe(true); expect(r.basis).toBe("total"); }); it("holds when the lines fall short — a shot is missing", () => { const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, subtotal: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }] }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/14\.00.*28\.00/); }); it("tolerance is $1 or 2%, whichever is larger", () => { expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 9.05 }] }).ok).toBe(true); expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 8.9 }] }).ok).toBe(false); expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 196.5 }] }).ok).toBe(true); expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 195 }] }).ok).toBe(false); }); it("derives a line from quantity × unit price when no line total is printed", () => { const r = reconcileItems({ merchant: "x", order_date: "d", total: 13.2, subtotal: 13.2, items: [{ quantity: 3, description: "Coke", unit_price: 4.4 }] }); expect(r.ok).toBe(true); expect(r.items_sum).toBe(13.2); }); it("no items at all is held; priceless lines (pasted text) are not", () => { expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [] }).ok).toBe(false); expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [{ quantity: 1, description: "a" }] }).ok).toBe(true); }); }); describe("toLineItems — names verbatim, options kept", () => { it("keeps a burger called Michael called Michael", () => { const out = toLineItems([{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }]); expect(out).toEqual([{ qty: 1, description: "Michael", amount: 14, unit_price: 14, options: ["Milk Bun"] }]); }); it("drops nameless lines and defaults quantity to 1", () => { const out = toLineItems([{ quantity: null, description: " ", line_total: 1 }, { quantity: null, description: "Cucumbers Continental", unit_price: 2.9 }]); expect(out).toEqual([{ qty: 1, description: "Cucumbers Continental", amount: 2.9, unit_price: 2.9 }]); }); }); describe("extraction request and reply", () => { it("one call: the prompt first, then every part in the order shared", () => { const content = buildMessageContent([ { kind: "image", mime: "image/png", bytes: Buffer.from("a"), name: "1.png" }, { kind: "text", text: "Total $22.40", name: "r.pdf" }, { kind: "image", mime: "image/jpeg", bytes: Buffer.from("b"), name: "2.jpg" }, ]) as { type: string; text?: string; image_url?: { url: string } }[]; expect(content[0].text).toMatch(/VERBATIM/); const images = content.filter((c) => c.type === "image_url").map((c) => c.image_url!.url); expect(images).toEqual(["data:image/png;base64,YQ==", "data:image/jpeg;base64,Yg=="]); expect(content.findIndex((c) => c.text?.includes("Total $22.40"))).toBeLessThan(content.findIndex((c) => c.image_url?.url.endsWith("Yg=="))); expect(EXTRACT_RESPONSE_FORMAT.json_schema.strict).toBe(true); }); it("parses a fenced reply and coerces numbers", () => { const r = parseExtraction('```json\n{"merchant":"Burgertory (Werribee)","order_date":"2026-08-19","total":"22.40","subtotal":28,"currency":null,"items":[{"quantity":1,"description":"Michael","unit_price":14,"line_total":14,"options":["Milk Bun"]}],"adjustments":[{"label":"Promotion","amount":-8}]}\n```'); expect(r.total).toBe(22.4); expect(r.currency).toBe("AUD"); expect(r.items[0]).toEqual({ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }); expect(r.adjustments).toEqual([{ label: "Promotion", amount: -8 }]); }); }); describe("PDF text — the Uber Eats export is born-digital text", () => { it("Coles (Wyndham Vale) · 2026-08-18 · $85.74, 15 lines, weighed and substituted", async () => { const text = await extractPdfText(pdf("ubereats-coles-2026-08-18.pdf")); expect(text).toContain("Here's your receipt for Coles (Wyndham Vale)."); expect(text).toContain("18 Aug, 2026"); expect(text).toContain("Total $85.74"); expect(text).toContain("Item Subtotal $118.94"); expect(text).toContain("Final 1.16 kg $11.00/kg"); expect(text).toContain("Replaced Coles Beef Slow Cook Chuck Roast"); // 15 item lines: 13 priced per piece, 2 weighed per kg. expect(text.match(/\$\d+\.\d\d\/pc$/gm)?.length).toBe(13); expect(text.match(/\$\d+\.\d\d\/kg$/gm)?.length).toBe(2); }); it("Burgertory (Werribee) · 2026-08-19 · $22.40, burgers named Michael and Joel", async () => { const text = await extractPdfText(pdf("ubereats-burgertory-2026-08-19.pdf")); expect(text).toContain("Here's your receipt for Burgertory (Werribee)."); expect(text).toContain("Total $22.40"); expect(text).toContain("1 Michael $14.00"); expect(text).toContain("1 Joel $14.00"); expect(text).toContain("Item Subtotal $28.00"); }); }); describe("cards carry the C5 action ids and JSON values", () => { const c = { batchId: "11111111-2222-4333-8444-555555555555", merchant: "Burgertory (Werribee)", total: 22.4, orderDate: "2026-08-19", platform: "ubereats", itemCount: 2 }; it("candidates: one Use-this per candidate plus an explicit Create", () => { const { blocks } = candidatesCard(c, [{ transaction_id: 42, description: "Order - Burgertory (Werribee)", transaction_date: "2026-08-17", amount: "19.90" }]); const buttons = JSON.stringify(blocks); expect(buttons).toContain('"action_id":"intake_use_candidate"'); expect(buttons).toContain('"action_id":"intake_create"'); const use = (blocks[1] as { accessory: { value: string } }).accessory.value; expect(JSON.parse(use)).toEqual({ batch_id: c.batchId, transaction_id: 42 }); }); it("enriched: the confirmation text and a View link", () => { const { text, blocks } = enrichedCard({ ...c, transactionId: 7 }); expect(text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓"); expect(JSON.stringify(blocks)).toContain("/transactions?q=Burgertory"); }); it("ask: Groceries / A meal", () => { const ids = JSON.stringify(askCard({ ...c, platform: null }).blocks); expect(ids).toContain('"action_id":"intake_grocery"'); expect(ids).toContain('"action_id":"intake_meal"'); }); it("every value the card did not write is escaped, plain_text included", () => { const hostile = { ...c, merchant: "Ben & Jerry's " }; const { text, blocks } = candidatesCard(hostile, [ { transaction_id: 42, description: "SQ *BEN & JERRY ", transaction_date: "2026-08-17", amount: "19.90" }, ]); const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section")); expect(sections).toContain("Ben & Jerry's <fake>"); expect(sections).toContain("SQ *BEN & JERRY <AU>"); expect(text).toContain("Ben & Jerry's <fake>"); // plain_text disables formatting, not entity decoding — &, < and > are // control characters everywhere in a Slack payload. const whole = JSON.stringify(blocks); expect(whole).not.toContain(""); expect(whole).not.toContain(""); }); it("the currency is model output too, so it is escaped like any other", () => { // parseExtraction accepts any string for `currency`; unescaped, `<@U123>` // would be a live mention on every card the batch produces. const { text } = enrichedCard({ ...c, currency: "<@U123>", transactionId: 7 }); expect(text).toContain("<@U123> 22.40"); expect(text).not.toContain("<@U123>"); }); it("a foreign receipt is not printed as dollars — header and candidate rows alike", () => { expect(enrichedCard({ ...c, currency: "USD", transactionId: 7 }).text).toBe( "Added 2 items to your USD 22.40 Uber Eats (Burgertory (Werribee)) order ✓" ); expect(enrichedCard({ ...c, currency: "AUD", transactionId: 7 }).text).toContain("$22.40"); expect(enrichedCard({ ...c, transactionId: 7 }).text).toContain("$22.40"); // A candidate row is a ledger transaction and carries its own currency; it // used to print "$" beneath a header reading "USD 22.40". const { blocks } = candidatesCard({ ...c, currency: "USD" }, [ { transaction_id: 42, description: "Order - Burgertory", transaction_date: "2026-08-17", amount: "19.90", currency: "USD" }, ]); expect(JSON.stringify(blocks)).toContain("USD 19.90"); }); }); describe("liteLLMExtractor — a finish_reason that is present must be `stop`", () => { const good = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [], adjustments: [] }; const extractor = (finish_reason?: string) => liteLLMExtractor({ baseUrl: "http://litellm.test", key: "k", fetchImpl: (async () => Response.json({ choices: [{ message: { content: JSON.stringify(good) }, ...(finish_reason ? { finish_reason } : {}) }], })) as unknown as typeof fetch, }); it("accepts `stop`, and — on purpose — a reply that reports no reason at all", async () => { expect((await extractor("stop")([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4); expect((await extractor()([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4); }); it("rejects any other reason, naming it — the strict schema only held for a reply that finished", async () => { // An absent reason is NOT rejected: see the comment in intake-extract.ts. await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(ExtractionError); await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/too long/); await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/); await expect(extractor("tool_calls")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/tool_calls/); }); }); describe("textCouldBeReceipt — nothing that cannot be a receipt reaches the model", () => { const why = (t: string | null | undefined) => { const r = textCouldBeReceipt(t); return r.ok ? null : r.reason; }; it("the live defect: the phone sent the WORD `Image`, not the image", () => { expect(textCouldBeReceipt("Image").ok).toBe(false); expect(why("Image")).toMatch(/single word/); expect(why("Image\n")).toMatch(/single word/); }); it("a filename is not a receipt, digits in it notwithstanding", () => { expect(why("IMG_0042.jpeg")).toMatch(/single word/); expect(why("Receipt_19Aug2026.pdf")).toMatch(/single word/); }); it("empty, blank and whitespace-only", () => { expect(why("")).toMatch(/empty/); expect(why(" \n\t ")).toMatch(/empty/); expect(why(null)).toMatch(/empty/); expect(why(undefined)).toMatch(/empty/); }); it("prose with no numbers cannot be a receipt", () => { expect(why("Shared from Photos")).toMatch(/no numbers/); expect(why("here is the receipt for dinner last night, sorry it took so long")).toMatch(/no numbers/); }); it("short but with a printed amount goes through — this door accepts a pasted line", () => { expect(textCouldBeReceipt("TOTAL $17.00").ok).toBe(true); expect(textCouldBeReceipt("Total 17.00").ok).toBe(true); expect(textCouldBeReceipt("Gesamt 12,50 EUR").ok).toBe(true); }); it("short with digits but no money figure does not — a screenshot name is not a purchase", () => { expect(why("Screenshot 2026 08 22")).toMatch(/too short/); expect(why("2 items")).toMatch(/too short/); }); it("a real receipt body passes", () => { expect( textCouldBeReceipt("Burgertory (Werribee)\n19 Aug 2026\n1x Michael 14.00\n1x Joel 14.00\nSubtotal 28.00\nTotal 22.40").ok ).toBe(true); }); it("quoteShared flattens and truncates what goes on the card", () => { expect(quoteShared("Image")).toBe("Image"); expect(quoteShared(" a \n b ")).toBe("a b"); expect(quoteShared("x".repeat(200))).toHaveLength(80); expect(quoteShared("x".repeat(200)).endsWith("…")).toBe(true); }); }); describe("confabulationReason — the belt to that pre-check", () => { const nothing = { textParts: ["Image"], hasImages: false }; const readable = { textParts: ["Total $22.40 Burgertory (Werribee) 2026-08-19"], hasImages: false }; const order = { merchant: "Burgertory (Braybrook)", items: [{ description: "The Pride" }] }; it("flags an order extracted from nothing readable", () => { expect(confabulationReason(nothing, order)).toMatch(/nothing shared could be read/); }); it("does not flag one extracted from something readable", () => { expect(confabulationReason(readable, order)).toBeNull(); }); it("does not flag an image share — the pixels are what we asked for", () => { expect(confabulationReason({ textParts: [], hasImages: true }, order)).toBeNull(); }); it("an empty answer to an unreadable share is honest, not a confabulation", () => { expect(confabulationReason(nothing, { merchant: null, items: [] })).toBeNull(); }); it("the prompt's own placeholder coming back as a merchant is always a confabulation", () => { for (const p of PROMPT_PLACEHOLDER_MERCHANTS) { expect(confabulationReason(readable, { merchant: p, items: [] })).toMatch(/prompt's own example/); expect(confabulationReason({ textParts: [], hasImages: true }, { merchant: p, items: [] })).toMatch(/prompt's own example/); } }); it("a real merchant that happens to be named in the prompt's platform list is NOT flagged", () => { expect(confabulationReason(readable, { merchant: "Coles (Wyndham Vale)", items: [] })).toBeNull(); expect(confabulationReason(readable, { merchant: "Woolworths", items: [] })).toBeNull(); }); }); describe("the prompt carries no real merchant to echo", () => { it("names no shop it could hand back as data", () => { expect(ORDER_ITEM_PROMPT).not.toMatch(/Burgertory/); expect(ORDER_ITEM_PROMPT).not.toMatch(/Wyndham Vale/); expect(ORDER_ITEM_PROMPT).toContain(PROMPT_PLACEHOLDER_MERCHANTS[0]); }); it("tells the model what to return when the input is not a receipt", () => { expect(ORDER_ITEM_PROMPT).toMatch(/NOT a receipt/); expect(ORDER_ITEM_PROMPT).toMatch(/Do not construct a plausible order/); }); }); describe("needsHeaderCard", () => { it("names the missing field and sends a dateless in-store screenshot to the app's Share button", async () => { const { needsHeaderCard } = await import("@/lib/intake-blocks"); const dateless = needsHeaderCard(["date"], "woolworths").text; expect(dateless).toContain("no date"); expect(dateless).toContain("Share button"); expect(dateless).not.toContain("total"); const delivery = needsHeaderCard(["total"], "uber_eats").text; expect(delivery).toContain("no total"); expect(delivery).toContain("part of the screen"); expect(needsHeaderCard().text).toContain("total or date"); }); });