import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { classifyInput, merchantKey, merchantMatches, platformForSource, reconcileItems, resolveMerchantKind, toLineItems, validateHeader, type ExtractedOrder, } from "../../lib/intake-rules"; import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT } from "../../lib/intake-extract"; import { askCard, candidatesCard, enrichedCard } 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); }); }); 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"'); }); });