test(intake): cover the review findings, and CLAUDE.md for the rules they set
ci / lint-test (push) Successful in 59s
ci / lint-test (push) Successful in 59s
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -49,6 +49,7 @@ let fileDir: string;
|
||||
let intakePOST: (req: NextRequest) => Promise<Response>;
|
||||
let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
let slackPOST: (req: NextRequest) => Promise<Response>;
|
||||
let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
|
||||
/** 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<Record<string, unknown>>()),
|
||||
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<string, unknown> };
|
||||
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]));
|
||||
|
||||
@@ -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 <fake>" };
|
||||
const { text, blocks } = candidatesCard(hostile, [
|
||||
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", 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("<fake>");
|
||||
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 <fake>");
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user