From 631e75e58f34eda0dc82119cee826a335be6fe96 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Fri, 21 Aug 2026 17:53:06 +1000 Subject: [PATCH] test(intake): cover the review findings, and CLAUDE.md for the rules they set Unit: word-boundary merchant matching (Bigals Pizza is not an IGA), mrkdwn escaping vs the plain_text button label, a foreign total on a card, and the finish_reason gate. Integration: the NULL-platform wildcard on both the food and grocery paths, candidates crossing platforms, EXCLUDE_RECONCILED_SOURCE, the pinned metadata row, the panel reading the row the write landed on, the once-per-batch undo baseline, the priceless-upload hold, Create re-matching, two pending cards booking once, a pantry-resolved sibling, and a USD receipt. Eleven of the thirteen fail against the pre-fix tree; the exceptions are the no-batch-id baseline guard (a no-change assertion) and the concurrent-tap one, which is a race and passed either way. --- CLAUDE.md | 31 +++- src/__tests__/integration/intake.test.ts | 219 ++++++++++++++++++++++- src/__tests__/unit/intake-rules.test.ts | 65 ++++++- 3 files changed, 310 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 216a47a..7bc9d75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -640,7 +640,36 @@ and the cross-repo contract are in smarthome `plans/intake-router-slice-plan.md` `intake_use_candidate` / `intake_create` buttons; a transaction is created only on the explicit tap, ING-10-style (`statement_id NULL`, `owner_id` from `shared_by` → `participants.email`, else `DEFAULT_OWNER_ID`; - `source='intake'`, `source_ref=batch_id` so a second press is a no-op). + `source='intake'`, `source_ref=batch_id` so a second press is a no-op — + written as `ON CONFLICT … DO NOTHING`, because a SELECT-then-INSERT loses a + double tap). A non-AUD receipt is booked the way `order-ingestion.ts` books + one: `amount` native, `amount_aud` **NULL**, `foreign_currency_*` set — no FX + rate exists at intake and inventing one is worse than admitting there is none. +- **A NULL platform means "no platform", not "any platform".** An in-store tap + (`coles`, `woolworths`, `other`) matches only rows whose `expense_metadata` + carries no platform of its own. The wildcard reading let a till receipt match + the Uber Eats order for the same shop on the same day and overwrite its items. + The **candidates** query is the opposite and deliberately so: it has no + platform filter at all, because the card is a human choice shown precisely + because the exact key missed — the platform is one of the things that may be + wrong, so the list must widen there, not narrow. +- **The Create tap re-runs the match before it inserts**, and checks for a + resolved sibling batch. The card is minutes old by the time it is pressed and + the email/order bridge books orders on its own schedule; `findDuplicateBatch` + ignores *unresolved* batches by design, so two pending cards for one order + would otherwise each book it. +- **One `expense_metadata` row, agreed on by three queries.** A transaction can + own a row *and* be the `matched_transaction_id` of another; `matchTransaction` + returns the row it resolved through and `replaceLineItems` writes to exactly + that one. All three (match, write, `GET /api/transactions/[id]/order`) order + by `(transaction_id = $1) DESC **NULLS LAST**, id` — the `NULLS LAST` is + load-bearing, because Postgres orders `DESC` as `NULLS FIRST` and the row that + merely *points* at the transaction has a NULL `transaction_id`, so the clause + meant to prefer the owned row preferred the other one. +- **`intake_batches.previous_line_items` is the undo baseline, written once.** + Returning whatever the write just read makes a pantry retry get its own write + back as the "previous" state, so undo restores the thing it undid. Recorded + under `COALESCE(previous_line_items, …)` and read back. - **Grocery never writes finance here.** The ORIGINAL files go to pantry `POST /api/capture` with `intake.finance_target_transaction_id` + `finance_action: enrich|create`; pantry's confirm calls diff --git a/src/__tests__/integration/intake.test.ts b/src/__tests__/integration/intake.test.ts index 2297221..50dc68d 100644 --- a/src/__tests__/integration/intake.test.ts +++ b/src/__tests__/integration/intake.test.ts @@ -49,6 +49,7 @@ let fileDir: string; let intakePOST: (req: NextRequest) => Promise; let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise; let slackPOST: (req: NextRequest) => Promise; +let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise; /** What the next LiteLLM call answers, and what the next pantry call answers. */ let modelReply: ExtractedOrder | Error = burgertory; @@ -104,6 +105,10 @@ async function seedOrder(over: { merchant: string; date: string; amount: number; } return id; } +/** What `components/order-details.tsx` would actually render for a transaction. */ +const orderPanel = async (txnId: number) => + (await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null; +const metaRows = async () => (await pool.query(`SELECT id, platform, line_items FROM expense_metadata ORDER BY id`)).rows as { id: number; platform: string | null; line_items: { description: string }[] }[]; const lineItemsOf = async (txnId: number) => (await pool.query(`SELECT line_items FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1`, [txnId])).rows[0]?.line_items; const batch = async (id: string) => (await pool.query(`SELECT * FROM intake_batches WHERE id = $1`, [id])).rows[0]; @@ -127,9 +132,17 @@ beforeAll(async () => { participantForSlackUser: () => 1, slackUserForParticipant: () => null, })); + // The order-detail panel is the surface an enrichment has to appear on, so + // the read is exercised beside the write rather than assumed. + vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: 1, name: "Owner", email: "o@x" }) })); + vi.doMock("@/lib/queries", async (importOriginal) => ({ + ...(await importOriginal>()), + canAccessTransactions: async () => true, + })); ({ POST: intakePOST } = await import("../../app/api/intake/route")); ({ PUT: lineItemsPUT } = await import("../../app/api/transactions/[id]/line-items/route")); ({ POST: slackPOST } = await import("../../app/api/slack/interactive/route")); + ({ GET: orderGET } = await import("../../app/api/transactions/[id]/order/route")); stubFetch(); }); @@ -210,6 +223,53 @@ describe("PUT /api/transactions/[id]/line-items (C2)", () => { expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" }); }); + it("the undo baseline is recorded ONCE per batch: a retry gets the original, not its own write", async () => { + const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + const original = [{ qty: 1, description: "what the order already had", amount: 22.4 }]; + await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(original)]); + const id = uuid(); + await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]); + + const first = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json(); + expect(first.previous_line_items).toEqual(original); + // Pantry re-calls on every confirm and after a timeout. Before, the retry + // was handed its OWN write back, so undo restored the thing it undid. + const retry = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json(); + expect(retry.previous_line_items).toEqual(original); + expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual(original); + + await put(txn, { line_items: retry.previous_line_items, source: "pantry", batch_id: id, replace: true }); + expect(await lineItemsOf(txn)).toEqual(original); + }); + + it("without a batch id the baseline is still the row as read — nothing else changes", async () => { + const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + const first = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json(); + expect(first.previous_line_items).toEqual([]); + const second = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json(); + expect(second.previous_line_items).toEqual(items); + }); + + it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => { + const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); + // Inserted first: the delivery receipt pointing at the statement line. + await pool.query( + `INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`, + [t, uuid()] + ); + // Inserted second: a row the transaction owns. An unordered LIMIT 1 reads + // the first one, the write takes the second, and the enrichment reports + // success against a row nothing renders. + await pool.query( + `INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'pantry', $2, '[]'::jsonb)`, + [t, uuid()] + ); + const written = await (await put(t, { line_items: items, source: "intake", replace: true })).json(); + const panel = await orderPanel(t); + expect(panel?.line_items).toHaveLength(2); + expect((await metaRows()).find((r) => r.id === written.metadata_id)!.line_items).toHaveLength(2); + }); + it("finds the row through matched_transaction_id — a card-settled order has only that", async () => { const statementLine = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); await pool.query( @@ -285,10 +345,82 @@ describe("POST /api/intake — food (enrich vs candidates)", () => { expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); }); - it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match", async () => { - await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" }); + it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match — but it IS offered", async () => { + const doordash = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" }); const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); expect(body.status).toBe("candidates"); + // The candidates card is a human choice, not a decision. The platform is one + // of the things that may be wrong, so it must not narrow the list — a + // platform-filtered card showed nothing at all here. + expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([doordash]); + }); + + it("a NULL platform is 'no platform', not a wildcard — an in-store share does not overwrite the delivery order", async () => { + await pool.query(`INSERT INTO intake_merchant_kinds (merchant_key, kind) VALUES ('burgertory werribee', 'food')`); + const delivery = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "ubereats" }); + const already = [{ qty: 1, description: "what the delivery order already had", amount: 22.4 }]; + await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [delivery, JSON.stringify(already)]); + + // `other` implies no platform; the delivery row carries one, so it is not + // the match — it is only a candidate. + const body = await (await intakePOST(multipart({ source: "other" }, [pdf]))).json(); + expect(body.status).toBe("candidates"); + expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([delivery]); + expect(await lineItemsOf(delivery)).toEqual(already); + }); + + it("a reconciled manual row is neither matched nor offered — EXCLUDE_RECONCILED_SOURCE, as everywhere else", async () => { + const live = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + const reconciled = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, reconciled]); + const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 }); + await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, near]); + + // Two rows at the exact key would have read as an ambiguity; one of them is + // the same spend counted twice. + const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); + expect(body).toMatchObject({ status: "enriched", transaction_id: live }); + }); + + it("the write lands on the row the MATCH resolved through, not on whichever row sorts first", async () => { + const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false }); + // The statement line owns a receipt row of its own (a pantry confirm) … + const grocery = [{ qty: 1, description: "a grocery shop", amount: 22.4 }]; + const own = (await pool.query( + `INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items) VALUES ($1, 'pantry', $2, 'Woolworths', $3::jsonb) RETURNING id`, + [t, uuid(), JSON.stringify(grocery)] + )).rows[0].id as number; + // … and is also the card leg of the Uber Eats order being enriched. + const delivery = (await pool.query( + `INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, merchant_normalized, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, 'Burgertory (Werribee)', '[]'::jsonb) RETURNING id`, + [t, uuid()] + )).rows[0].id as number; + + const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); + expect(body).toMatchObject({ status: "enriched", transaction_id: t }); + const rows = await metaRows(); + expect(rows.find((r) => r.id === delivery)!.line_items.map((i) => i.description)).toEqual(["Michael", "Joel"]); + expect(rows.find((r) => r.id === own)!.line_items).toEqual(grocery); + }); + + it("an upload with no prices never replaces a receipt that already has items", async () => { + const priceless = { ...burgertory, subtotal: null, total: 22.4, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) }; + const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }]; + await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]); + + modelReply = priceless; + const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); + expect(body).toMatchObject({ kind: "held", status: "held" }); + expect(body.text).toMatch(/left the existing items alone/); + expect(await lineItemsOf(txn)).toEqual(already); + + // …but an EMPTY receipt is exactly what this slice exists to fill. + const empty = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-20", amount: 22.4 }); + modelReply = { ...priceless, order_date: "2026-08-20" }; + const second = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json(); + expect(second).toMatchObject({ status: "enriched", transaction_id: empty }); + expect((await lineItemsOf(empty)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); }); it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => { @@ -391,6 +523,19 @@ describe("POST /api/intake — grocery → pantry", () => { expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn }); }); + it("an in-store tap does not hand pantry the delivery order as its finance target", async () => { + modelReply = coles; + // The Uber Eats Coles order the email pipeline already booked. + const delivery = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: "ubereats" }); + // The same shop, same day, same money — but shared from the Coles app, so + // it carries no platform and is a different purchase. + const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json(); + expect(body.status).toBe("forwarded_to_pantry"); + const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record }; + expect(pantryCall.intake).toMatchObject({ finance_action: "create", finance_target_transaction_id: null, platform: null }); + expect(await lineItemsOf(delivery)).toEqual([]); + }); + it("grocery + no match → pantry with finance_action create (ING-10 as today)", async () => { modelReply = coles; const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json(); @@ -457,6 +602,76 @@ describe("Slack taps (C5)", () => { expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); }); + it("intake_create re-runs the match: an order booked between the card and the tap is enriched, not duplicated", async () => { + const id = uuid(); + const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json(); + expect(body.status).toBe("candidates"); + + // The email/order bridge books the same purchase while the card sits in + // Slack. The card's "no match" is minutes stale by the time it is pressed. + const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 }); + + await slackPOST(slackPress("intake_create", { batch_id: id })); + expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); + expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn }); + expect((await lineItemsOf(txn)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]); + expect(lastSlackUpdate().text).toMatch(/^Added 2 items/); + }); + + it("two pending cards for one order book it ONCE", async () => { + const a = uuid(); + const b = uuid(); + await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf])); + await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }])); + expect((await batch(a)).status).toBe("candidates"); + expect((await batch(b)).status).toBe("candidates"); + + await slackPOST(slackPress("intake_create", { batch_id: a })); + await slackPOST(slackPress("intake_create", { batch_id: b })); + expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); + expect((await batch(b)).transaction_id).toBe((await batch(a)).transaction_id); + }); + + it("a sibling already resolved to the pantry board makes Create a duplicate, not a second row", async () => { + // Two shares of one receipt, resolved out of order: nothing the second card + // can match on, because the first one never produced a transaction. + modelReply = { ...coles, merchant: "Romeo's Food Hall" }; + const a = uuid(); + const b = uuid(); + await intakePOST(multipart({ source: "other", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }])); + await intakePOST(multipart({ source: "other", batch_id: a }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }])); + expect((await batch(a)).status).toBe("ask"); + + await slackPOST(slackPress("intake_grocery", { batch_id: a })); + expect((await batch(a)).status).toBe("forwarded_to_pantry"); + + await slackPOST(slackPress("intake_meal", { batch_id: b })); + expect((await batch(b)).status).toBe("candidates"); + await slackPOST(slackPress("intake_create", { batch_id: b })); + expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0); + expect(await batch(b)).toMatchObject({ status: "duplicate", capture_id: 901 }); + expect(lastSlackUpdate().text).toMatch(/^Already handled/); + }); + + it("two taps that land together still book one transaction, and neither reports an error", async () => { + const id = uuid(); + await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); + await Promise.all([slackPOST(slackPress("intake_create", { batch_id: id })), slackPOST(slackPress("intake_create", { batch_id: id }))]); + expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1); + const texts = calls.filter((c) => c.url.includes("hooks.slack.com")).map((c) => (c.body as { text: string }).text); + expect(texts.every((t) => !/Couldn't process/.test(t))).toBe(true); + }); + + it("a foreign receipt is booked in its own currency, with no invented AUD figure", async () => { + modelReply = { ...burgertory, currency: "USD" }; + const id = uuid(); + await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf])); + await slackPOST(slackPress("intake_create", { batch_id: id })); + const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0]; + expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" }); + expect(lastSlackUpdate().text).toMatch(/USD 22\.40/); + }); + it("unknown shared_by falls back to the default owner — never a NULL owner", async () => { const id = uuid(); await intakePOST(multipart({ source: "uber_eats", shared_by: "nobody@example.test", batch_id: id }, [pdf])); diff --git a/src/__tests__/unit/intake-rules.test.ts b/src/__tests__/unit/intake-rules.test.ts index 5e5a115..efd41f1 100644 --- a/src/__tests__/unit/intake-rules.test.ts +++ b/src/__tests__/unit/intake-rules.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "fs"; import { resolve } from "path"; import { classifyInput, + GROCERY_PATTERN, merchantKey, merchantMatches, platformForSource, @@ -12,8 +13,8 @@ import { 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"; +import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT, ExtractionError, liteLLMExtractor } 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. @@ -85,6 +86,17 @@ describe("merchant pattern — grocery | food | ask", () => { 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", () => { @@ -221,4 +233,53 @@ describe("cards carry the C5 action ids and JSON values", () => { expect(ids).toContain('"action_id":"intake_grocery"'); expect(ids).toContain('"action_id":"intake_meal"'); }); + + it("mrkdwn escapes the names it did not write; the plain_text button keeps them raw", () => { + 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(sections).not.toContain(""); + expect(text).toContain("Ben & Jerry's <fake>"); + // The Create button's label is plain_text — Slack shows entities literally + // there, so escaping it would print "Ben & Jerry's" on the button. + const actions = JSON.stringify(blocks.filter((b) => (b as Block).type === "actions")); + expect(actions).toContain("Ben & Jerry's "); + }); + + it("a foreign receipt is not printed as dollars", () => { + 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"); + }); +}); + +describe("liteLLMExtractor — only a finished reply is accepted", () => { + 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 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 () => { + 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/); + }); });