Unit: merchant pattern, header validation, reconciliation tolerance, verbatim line items, one-call message shape, card action ids; unpdf over the two real Uber Eats exports (Coles $85.74 / 15 lines, Burgertory $22.40 / Michael + Joel). Integration: fetch stubbed at the LiteLLM and pantry boundaries so the PDF path, persistence, match and writes run for real against personal_test. intake-live.test.ts is opt-in (INTAKE_LIVE_TEST=1) and was run green against cloud-budget on both PDFs.
225 lines
12 KiB
TypeScript
225 lines
12 KiB
TypeScript
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"');
|
||
});
|
||
});
|