test(intake): rules, PDF text, C2 idempotency, enrich-vs-candidates, pantry hand-off, Slack taps
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.
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { readFileSync, mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants } from "./helpers";
|
||||
import type { ExtractedOrder } from "../../lib/intake-rules";
|
||||
|
||||
/**
|
||||
* The intake router against the real schema (plan §11 C1, C2, C5).
|
||||
*
|
||||
* The model is stubbed at the HTTP boundary — `fetch` to LiteLLM answers with a
|
||||
* canned extraction, `fetch` to pantry with a canned capture — so the PDF text
|
||||
* path, the persistence, the match and the writes all run for real. The two
|
||||
* PDFs are the user's own Uber Eats exports; the transactions seeded here are
|
||||
* shaped like production rows 6846 and 6857 (platform `ubereats`, `line_items`
|
||||
* empty, the email pipeline's booking of the same two orders).
|
||||
*/
|
||||
|
||||
const INTAKE = "test-intake-token";
|
||||
const RECEIPT = "test-receipt-token";
|
||||
const fixtures = resolve(__dirname, "../fixtures/intake");
|
||||
const BURGERTORY = readFileSync(resolve(fixtures, "ubereats-burgertory-2026-08-19.pdf"));
|
||||
const COLES = readFileSync(resolve(fixtures, "ubereats-coles-2026-08-18.pdf"));
|
||||
|
||||
const burgertory: ExtractedOrder = {
|
||||
merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] },
|
||||
{ quantity: 1, description: "Joel", unit_price: 14, line_total: 14, options: [] },
|
||||
],
|
||||
adjustments: [{ label: "Delivery Fee", amount: 5.49 }, { label: "Promotion", amount: -8 }],
|
||||
};
|
||||
const coles: ExtractedOrder = {
|
||||
merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 3, description: "Coca-Cola Zero Sugar Soft Drink (2L)", unit_price: 4.4, line_total: 13.2, options: [] },
|
||||
{ quantity: 1, description: "Coles RSPCA Approved Chicken Thigh Cutlets approx. 1.1kg", unit_price: 11, line_total: 12.76, options: ["Final 1.16 kg $11.00/kg"] },
|
||||
{ quantity: 1, description: "everything else", unit_price: 92.98, line_total: 92.98, options: [] },
|
||||
],
|
||||
adjustments: [],
|
||||
};
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
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>;
|
||||
|
||||
/** What the next LiteLLM call answers, and what the next pantry call answers. */
|
||||
let modelReply: ExtractedOrder | Error = burgertory;
|
||||
let pantryReply: { status: number; body: unknown } = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
const calls: { url: string; body: unknown }[] = [];
|
||||
|
||||
function stubFetch() {
|
||||
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/v1/chat/completions")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
if (modelReply instanceof Error) return new Response("boom", { status: 500 });
|
||||
return Response.json({ choices: [{ message: { content: JSON.stringify(modelReply) }, finish_reason: "stop" }] });
|
||||
}
|
||||
if (u.includes("/api/capture")) {
|
||||
const form = init?.body as FormData;
|
||||
calls.push({ url: u, body: { intake: JSON.parse(String(form.get("intake"))), files: form.getAll("image").map((f) => (f as File).name), headers: init?.headers } });
|
||||
return Response.json(pantryReply.body, { status: pantryReply.status });
|
||||
}
|
||||
if (u.includes("hooks.slack.com")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
return new Response("ok");
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
});
|
||||
}
|
||||
|
||||
const multipart = (fields: Record<string, string>, files: { name: string; type: string; bytes: Buffer }[], token: string | null = INTAKE) => {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
|
||||
for (const f of files) fd.append("file", new File([new Uint8Array(f.bytes)], f.name, { type: f.type }));
|
||||
return new Request("http://finance.test/api/intake", { method: "POST", headers: token ? { "x-ingest-token": token } : {}, body: fd }) as unknown as NextRequest;
|
||||
};
|
||||
const jsonReq = (body: unknown, token: string | null) =>
|
||||
({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body }) as unknown as NextRequest;
|
||||
const put = (id: number | string, body: unknown, token: string | null = RECEIPT) => lineItemsPUT(jsonReq(body, token), { params: Promise.resolve({ id: String(id) }) });
|
||||
const uuid = () => crypto.randomUUID();
|
||||
|
||||
/** A transaction shaped like the email pipeline's booking of an order, line_items empty. */
|
||||
async function seedOrder(over: { merchant: string; date: string; amount: number; platform?: string | null; owner?: number; withMeta?: boolean }) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'dining', $5, $5, 'credits', 0) RETURNING id`,
|
||||
[over.owner ?? ownerId, over.date, `Order - ${over.merchant}`, over.amount, over.merchant]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
if (over.withMeta !== false) {
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency)
|
||||
VALUES ($1, 'email', $2, $3, '[]'::jsonb, $4, $5, $6, 'AUD')`,
|
||||
[id, over.platform === undefined ? "ubereats" : over.platform, uuid(), over.merchant, over.amount, over.date]
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
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];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.INTAKE_TOKEN = INTAKE;
|
||||
process.env.RECEIPT_INGEST_TOKEN = RECEIPT;
|
||||
process.env.ORDER_INGEST_TOKEN = "test-order-token";
|
||||
process.env.LITELLM_BASE_URL = "http://litellm.test";
|
||||
process.env.LITELLM_KEY = "k";
|
||||
process.env.PANTRY_BASE_URL = "http://pantry.test";
|
||||
process.env.FINANCE_PUBLIC_URL = "https://finance.test";
|
||||
fileDir = mkdtempSync(join(tmpdir(), "intake-test-"));
|
||||
process.env.INTAKE_FILE_DIR = fileDir;
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
// The Slack signature gate is exercised by slack-verify.test.ts; here it is
|
||||
// bypassed so the intake branch of the route is what is under test.
|
||||
vi.doMock("@/lib/slack-verify", () => ({
|
||||
verifySlackSignature: () => true,
|
||||
participantForSlackUser: () => 1,
|
||||
slackUserForParticipant: () => null,
|
||||
}));
|
||||
({ 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"));
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(fileDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query(`TRUNCATE intake_batches, intake_merchant_kinds`);
|
||||
({ ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]));
|
||||
await pool.query(`UPDATE participants SET email = 'bob@example.test' WHERE id = $1`, [otherId]);
|
||||
modelReply = burgertory;
|
||||
pantryReply = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
// ─── C2 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT /api/transactions/[id]/line-items (C2)", () => {
|
||||
const items = [{ qty: 1, description: "Michael", amount: 14, options: ["Milk Bun"] }, { qty: 1, description: "Joel", amount: 14 }];
|
||||
|
||||
it("fails closed: no token, wrong token", async () => {
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, null)).status).toBe(401);
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, "nope")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts either token", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "pantry", replace: true }, RECEIPT)).status).toBe(200);
|
||||
expect((await put(id, { line_items: items, source: "intake", replace: true }, INTAKE)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("404 when the transaction does not exist — it never creates one", async () => {
|
||||
const res = await put(999999, { line_items: items, source: "intake", replace: true });
|
||||
expect(res.status).toBe(404);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a body that is not a replace, a bad source, or nameless items", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "intake" })).status).toBe(400);
|
||||
expect((await put(id, { line_items: items, source: "n8n", replace: true })).status).toBe(400);
|
||||
expect((await put(id, { line_items: [{ qty: 1 }], source: "intake", replace: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it("idempotent replace: returns previous + new, and a second identical call is a no-op", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = await (await put(id, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual([]);
|
||||
expect(first.line_items).toHaveLength(2);
|
||||
expect(first.created_metadata).toBe(false);
|
||||
expect(await lineItemsOf(id)).toHaveLength(2);
|
||||
|
||||
const corrected = [{ qty: 1, description: "Michael (corrected)", amount: 14 }];
|
||||
const second = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(second.previous_line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await lineItemsOf(id)).toEqual(corrected);
|
||||
|
||||
const third = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(third.previous_line_items).toEqual(corrected);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("undo shape: PUT previous_line_items back restores the row", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const r = await (await put(id, { line_items: items, source: "pantry", replace: true })).json();
|
||||
await put(id, { line_items: r.previous_line_items, source: "pantry", replace: true });
|
||||
expect(await lineItemsOf(id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates the expense_metadata row when the transaction has none, with source and platform", async () => {
|
||||
const id = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||
const res = await (await put(id, { line_items: items, source: "intake", platform: "ubereats", batch_id: "b1", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(true);
|
||||
const em = (await pool.query(`SELECT source, platform, order_reference, merchant_normalized, amount::text FROM expense_metadata WHERE transaction_id = $1`, [id])).rows[0];
|
||||
expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" });
|
||||
});
|
||||
|
||||
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(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
|
||||
[statementLine, uuid()]
|
||||
);
|
||||
const res = await (await put(statementLine, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(false);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
expect(await lineItemsOf(statementLine)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C1 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/intake — gates", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("fails closed without INTAKE_TOKEN match", async () => {
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], null))).status).toBe(401);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], "nope"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("400 on a bad source, no files, too many files, a non-uuid batch_id", async () => {
|
||||
expect((await intakePOST(multipart({ source: "menulog" }, [pdf]))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, []))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, Array(7).fill(pdf)))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: "not-a-uuid" }, [pdf]))).status).toBe(400);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM intake_batches`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("declines a file type it cannot read, after persisting the batch", async () => {
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "clip.mp4", type: "video/mp4", bytes: Buffer.from("x") }]));
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("unsupported");
|
||||
expect(body.text).toMatch(/not this/);
|
||||
expect((await batch(id)).status).toBe("unsupported");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — food (enrich vs candidates)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("food + exact (platform, merchant, date, total) match → enriches the existing order, one model call, PDF as text", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toMatchObject({ kind: "food", status: "enriched", transaction_id: txn, batch_id: id });
|
||||
expect(body.text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("https://finance.test/transactions?q=Burgertory");
|
||||
|
||||
const stored = await lineItemsOf(txn);
|
||||
expect(stored.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(stored[0].options).toEqual(["Milk Bun"]);
|
||||
|
||||
// one joint call, the PDF's text in it (not an image)
|
||||
expect(calls).toHaveLength(1);
|
||||
const content = calls[0].body as { messages: { content: { type: string; text?: string }[] }[]; model: string; temperature: number; response_format: { type: string } };
|
||||
expect(content.model).toBe("cloud-budget");
|
||||
expect(content.temperature).toBe(0);
|
||||
expect(content.response_format.type).toBe("json_schema");
|
||||
expect(content.messages[0].content.some((c) => c.type === "image_url")).toBe(false);
|
||||
expect(content.messages[0].content.some((c) => c.text?.includes("1 Michael $14.00"))).toBe(true);
|
||||
|
||||
const row = await batch(id);
|
||||
expect(row).toMatchObject({ source: "uber_eats", shared_by: "bob@example.test", kind: "food", status: "enriched", transaction_id: txn });
|
||||
expect(row.header).toMatchObject({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, platform: "ubereats" });
|
||||
expect(row.resolved_at).not.toBeNull();
|
||||
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" });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
});
|
||||
|
||||
it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-10", amount: 22.4 }); // too far
|
||||
await seedOrder({ merchant: "Mad Mex", date: "2026-08-19", amount: 22.4 }); // wrong merchant
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([near]);
|
||||
const blocks = JSON.stringify(body.slack_blocks);
|
||||
expect(blocks).toContain('"action_id":"intake_use_candidate"');
|
||||
expect(blocks).toContain('"action_id":"intake_create"');
|
||||
expect(blocks).toContain(JSON.stringify(JSON.stringify({ batch_id: id, transaction_id: near })));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(3);
|
||||
expect(await lineItemsOf(near)).toEqual([]);
|
||||
expect((await batch(id)).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("two exact hits is an ambiguity, not a coin toss", async () => {
|
||||
const a = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const b = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id).sort()).toEqual([a, b].sort());
|
||||
});
|
||||
|
||||
it("missing header (no total) → needs_header, nothing written but the batch row", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, total: null };
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("needs_header");
|
||||
expect(body.text).toMatch(/share the top of the screen/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id)).status).toBe("needs_header");
|
||||
});
|
||||
|
||||
it("items that do not add up → held, nothing written", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, items: [burgertory.items[0]] };
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "held", status: "held" });
|
||||
expect(body.text).toMatch(/is a shot missing/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
});
|
||||
|
||||
it("a model failure is a 502 with the batch marked error, not a silent 200", async () => {
|
||||
modelReply = new Error("down");
|
||||
const res = await intakePOST(multipart({ source: "uber_eats" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
|
||||
it("re-share of the same file after it was handled → duplicate with a pointer", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
calls.length = 0;
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of).toEqual({ batch_id: first, transaction_id: txn, capture_id: null });
|
||||
expect(body.text).toMatch(/Already handled/);
|
||||
expect(calls).toHaveLength(0); // no model call spent on a known file
|
||||
});
|
||||
|
||||
it("re-share of the same order under a different file (screenshot after PDF) → duplicate by header", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [{ name: "IMG_1.png", type: "image/png", bytes: Buffer.from("fake-png") }]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of.transaction_id).toBe(txn);
|
||||
});
|
||||
|
||||
it("a repeated batch_id is refused, not re-run", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — grocery → pantry", () => {
|
||||
const pdf = { name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES };
|
||||
|
||||
it("grocery + match → the ORIGINAL file goes to pantry with the target transaction and action enrich; finance is NOT touched", async () => {
|
||||
modelReply = coles;
|
||||
const txn = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74 });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
expect(body.text).toBe("Coles (Wyndham Vale) $85.74 → pantry board (3 items) — finance updates on confirm");
|
||||
|
||||
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown>; files: string[]; headers: Record<string, string> };
|
||||
expect(pantryCall.headers["x-ingest-token"]).toBe(RECEIPT);
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toEqual({ batch_id: id, platform: "ubereats", origin: "intake", shared_by: "bob@example.test", finance_target_transaction_id: txn, finance_action: "enrich" });
|
||||
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
});
|
||||
|
||||
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();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
expect(body.text).toMatch(/finance books it on confirm/);
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown> };
|
||||
expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" });
|
||||
});
|
||||
|
||||
it("pantry 409 → duplicate", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } };
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "duplicate", capture_id: 77 });
|
||||
});
|
||||
|
||||
it("pantry down → 502 error, batch marked error", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 500, body: { error: "nope" } };
|
||||
const res = await intakePOST(multipart({ source: "coles" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C5 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const slackPress = (action_id: string, value: unknown) => {
|
||||
const payload = { type: "block_actions", user: { id: "U1", name: "alice" }, response_url: "https://hooks.slack.com/r/1", actions: [{ action_id, value: JSON.stringify(value) }] };
|
||||
const raw = new URLSearchParams({ payload: JSON.stringify(payload) }).toString();
|
||||
return { headers: { get: (h: string) => (h === "x-ingest-token" ? "test-order-token" : "x") }, text: async () => raw } as unknown as NextRequest;
|
||||
};
|
||||
const lastSlackUpdate = () => calls.filter((c) => c.url.includes("hooks.slack.com")).at(-1)?.body as { replace_original?: boolean; text: string; blocks?: unknown[] };
|
||||
|
||||
describe("Slack taps (C5)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("intake_use_candidate enriches the chosen transaction and replaces the card", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
const res = await slackPOST(slackPress("intake_use_candidate", { batch_id: id, transaction_id: near }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await lineItemsOf(near)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: near });
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: true, text: "Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓" });
|
||||
});
|
||||
|
||||
it("intake_create creates the transaction ING-10-style: manual, owner from shared_by, items attached; a second press is a no-op", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
const t = (await pool.query(`SELECT statement_id, owner_id, description, amount::text, category, transaction_date::text, source, source_ref FROM transactions`)).rows;
|
||||
expect(t).toEqual([{ statement_id: null, owner_id: otherId, description: "Order - Burgertory (Werribee)", amount: "22.40", category: "dining", transaction_date: "2026-08-19", source: "intake", source_ref: id }]);
|
||||
const em = (await pool.query(`SELECT source, platform, line_items FROM expense_metadata`)).rows[0];
|
||||
expect(em.source).toBe("intake");
|
||||
expect(em.platform).toBe("ubereats");
|
||||
expect(em.line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Created a \$22\.40 Burgertory \(Werribee\) transaction with 2 items/);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
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]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT owner_id FROM transactions`)).rows[0].owner_id).toBe(1);
|
||||
});
|
||||
|
||||
it("`other` + unrecognised merchant asks; intake_meal routes it as food and remembers the merchant", async () => {
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall" };
|
||||
const txn = await seedOrder({ merchant: "Romeo's Food Hall", date: "2026-08-19", amount: 22.4, platform: null });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "other", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "ask", status: "ask" });
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("intake_grocery");
|
||||
|
||||
await slackPOST(slackPress("intake_meal", { batch_id: id }));
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds WHERE merchant_key = 'romeo s food hall'`)).rows[0].kind).toBe("food");
|
||||
|
||||
// The answer sticks: the next share of that merchant does not ask.
|
||||
const again = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG.png", type: "image/png", bytes: Buffer.from("png2") }]))).json();
|
||||
expect(again.status).toBe("duplicate"); // same order — but it was routed, not asked
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall", order_date: "2026-08-20" };
|
||||
const next = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG3.png", type: "image/png", bytes: Buffer.from("png3") }]))).json();
|
||||
expect(next).toMatchObject({ kind: "food", status: "candidates" });
|
||||
});
|
||||
|
||||
it("intake_grocery forwards the stored original files to pantry", async () => {
|
||||
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "other", batch_id: id }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
|
||||
expect((await batch(id)).status).toBe("ask");
|
||||
await slackPOST(slackPress("intake_grocery", { batch_id: id }));
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown>; files: string[] };
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toMatchObject({ batch_id: id, finance_action: "create" });
|
||||
expect(await batch(id)).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901 });
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds`)).rows[0].kind).toBe("grocery");
|
||||
});
|
||||
|
||||
it("a tap on a resolved batch returns its card and writes nothing; an unknown batch says so", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
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(lastSlackUpdate().text).toMatch(/^Added 2 items/);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: uuid() }));
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: false, text: "I can't find that upload any more." });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { extractPdfText, liteLLMExtractor } from "../../lib/intake-extract";
|
||||
import { reconcileItems, toLineItems, validateHeader } from "../../lib/intake-rules";
|
||||
|
||||
/**
|
||||
* Opt-in: the real model over the real Burgertory export.
|
||||
*
|
||||
* INTAKE_LIVE_TEST=1 LITELLM_BASE_URL=http://localhost:4200 LITELLM_KEY=... npm test
|
||||
*
|
||||
* Skipped otherwise — CI has no LiteLLM, and a unit run must not depend on one.
|
||||
* What it proves is the thing the mock cannot: that `cloud-budget` keeps the
|
||||
* names verbatim ("Michael", "Joel", not "Burger") under the order-item prompt.
|
||||
*/
|
||||
const live = process.env.INTAKE_LIVE_TEST === "1" && !!process.env.LITELLM_BASE_URL && !!process.env.LITELLM_KEY;
|
||||
|
||||
describe.skipIf(!live)("live extraction via LiteLLM (opt-in)", () => {
|
||||
it("Burgertory PDF: header exact, two items named verbatim, reconciles", async () => {
|
||||
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-burgertory-2026-08-19.pdf")));
|
||||
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_19Aug2026.pdf" }]);
|
||||
const header = validateHeader(extracted);
|
||||
expect(header.ok && header.header).toEqual({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4 });
|
||||
const items = toLineItems(extracted.items);
|
||||
expect(items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(reconcileItems(extracted).ok).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("Coles PDF: header exact, 15 items", async () => {
|
||||
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-coles-2026-08-18.pdf")));
|
||||
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_18Aug2026.pdf" }]);
|
||||
const header = validateHeader(extracted);
|
||||
expect(header.ok && header.header).toEqual({ merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74 });
|
||||
expect(toLineItems(extracted.items)).toHaveLength(15);
|
||||
expect(reconcileItems(extracted).ok).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user