ci / lint-test (push) Failing after 41s
Trip rows read "Order - Uber Trip (Uber)". The suffix exists so you can tell where to go and look; when the merchant is literally "Uber Trip" it says nothing. What identifies a trip is its two addresses, and those are in the Order details panel. Existing rows updated in prod.
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
import {
|
|
parseOrderHTML,
|
|
validateOrderTotals,
|
|
resolveCategory,
|
|
NotAReceiptError,
|
|
orderDescription,
|
|
type MessageMeta,
|
|
type ParsedOrder,
|
|
} from "../../lib/order-ingestion";
|
|
|
|
/**
|
|
* Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures
|
|
* built to satisfy the parser, so it passed while the parser could not read a
|
|
* real email. These run against unmodified captured receipts.
|
|
*/
|
|
const dir = resolve(__dirname, "../fixtures/orders/real");
|
|
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
|
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
|
|
messageId: "unit-1",
|
|
subject: "Order Confirmation for Siddharth from Mad Mex",
|
|
receivedAt: "2026-07-16T03:34:00Z",
|
|
sender: "DoorDash Order <no-reply@doordash.com>",
|
|
...over,
|
|
});
|
|
|
|
describe("payment detection", () => {
|
|
it("credits-only", () => {
|
|
const p = parseOrderHTML(html("dd-01"), meta());
|
|
expect(p.payment.credits_amount).toBe(14.64);
|
|
expect(p.payment.card_last4).toBeNull();
|
|
expect(p.payment.ambiguous).toBe(false);
|
|
});
|
|
|
|
it("card-only produces no credits figure", () => {
|
|
const p = parseOrderHTML(
|
|
html("dd-27"),
|
|
meta({ subject: "Order Confirmation for Siddharth from Subway" })
|
|
);
|
|
expect(p.payment.card_last4).toBe("8032");
|
|
expect(p.payment.credits_amount).toBeNull();
|
|
});
|
|
|
|
it("'and/or credits' is ambiguous, not silently credits", () => {
|
|
// Regression: an earlier regex delimited the payment line on a double
|
|
// space, which whitespace collapsing removes. Every card and mixed receipt
|
|
// fell through to the credits branch — this one booked the whole $60.93 as
|
|
// credits spend that never happened.
|
|
const p = parseOrderHTML(
|
|
html("dd-10"),
|
|
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
|
|
);
|
|
expect(p.payment.ambiguous).toBe(true);
|
|
expect(p.payment.card_last4).toBe("8032");
|
|
expect(p.payment.credits_amount).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validateOrderTotals", () => {
|
|
const base = (over: Partial<ParsedOrder> = {}): ParsedOrder => ({
|
|
order_reference: "x",
|
|
platform: "doordash",
|
|
merchant_name: "M",
|
|
order_datetime: "2026-03-01T00:00:00Z",
|
|
currency: "AUD",
|
|
payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false },
|
|
totals: {
|
|
subtotal: null, taxes: null, delivery_fee: null,
|
|
service_fee: null, tip: null, discounts: null, total_charged: 10,
|
|
},
|
|
line_items: [],
|
|
route: [],
|
|
is_family: false,
|
|
flags: [],
|
|
...over,
|
|
});
|
|
|
|
it("rejects a non-positive total", () => {
|
|
const o = base();
|
|
o.totals.total_charged = 0;
|
|
expect(validateOrderTotals(o).ok).toBe(false);
|
|
});
|
|
|
|
it("rejects payments that do not account for the total", () => {
|
|
const r = validateOrderTotals(
|
|
base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } })
|
|
);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toMatch(/payments sum/);
|
|
});
|
|
|
|
it("accepts a matching total", () => {
|
|
expect(validateOrderTotals(base()).ok).toBe(true);
|
|
});
|
|
|
|
it("does not gate on DoorDash's non-reconciling fee breakdown", () => {
|
|
const p = parseOrderHTML(html("dd-01"), meta());
|
|
expect(p.totals.subtotal).toBe(22.10);
|
|
expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed
|
|
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("resolveCategory", () => {
|
|
const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") =>
|
|
({ merchant_name: merchant, platform }) as ParsedOrder;
|
|
|
|
it("maps grocers to groceries", () => {
|
|
expect(resolveCategory(o("Woolworths"))).toBe("groceries");
|
|
expect(resolveCategory(o("ALDI"))).toBe("groceries");
|
|
expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries");
|
|
});
|
|
|
|
it("maps restaurants to dining rather than 'other'", () => {
|
|
// The earlier six-merchant allowlist sent every one of these to `other`.
|
|
for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) {
|
|
expect(resolveCategory(o(m))).toBe("dining");
|
|
}
|
|
});
|
|
|
|
it("maps rides to transport", () => {
|
|
expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport");
|
|
});
|
|
});
|
|
|
|
describe("parse guards", () => {
|
|
it("throws on a body too short to be a receipt", () => {
|
|
expect(() => parseOrderHTML("<html></html>", meta())).toThrow(NotAReceiptError);
|
|
});
|
|
|
|
it("throws rather than inventing a platform", () => {
|
|
expect(() =>
|
|
parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" }))
|
|
).toThrow(/platform/i);
|
|
});
|
|
});
|
|
|
|
describe("order_reference anchoring", () => {
|
|
it("takes Uber's tripReference, not the first UUID in the document", () => {
|
|
const p = parseOrderHTML(
|
|
html("ue-00"),
|
|
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
|
|
);
|
|
// The UUID the PDF redirect actually resolves to for this receipt.
|
|
expect(p.order_reference).toBe("34d6b4ee-da8f-5029-8d14-bd359617c8e9");
|
|
expect(p.flags).not.toContain("order_uuid_ambiguous");
|
|
});
|
|
|
|
it("is stable across repeated parses of the same message", () => {
|
|
const m = meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" });
|
|
const a = parseOrderHTML(html("ue-00"), m).order_reference;
|
|
const b = parseOrderHTML(html("ue-00"), m).order_reference;
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("flags ambiguity only when several UUIDs and no anchor", () => {
|
|
// Strip the anchor from a receipt that carries multiple UUIDs (ue-09 has 6).
|
|
const stripped = html("ue-09").replace(/tripReference/gi, "notTheAnchor");
|
|
const p = parseOrderHTML(
|
|
stripped,
|
|
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
|
);
|
|
expect(p.flags).toContain("order_uuid_ambiguous");
|
|
});
|
|
});
|
|
|
|
describe("mixed Uber payment (issuer-named card leg)", () => {
|
|
it("captures both legs when the card is labelled by issuer, not brand", () => {
|
|
// Real receipt: Uber Cash $1.17 + Westpac ••••8032 $15.33 = $16.50.
|
|
// A brand allowlist (Visa|MasterCard|Amex) misses "Westpac" and drops the
|
|
// card half, leaving payments that do not account for the total.
|
|
const p = parseOrderHTML(
|
|
readFileSync(resolve(dir, "ue-mixed.html"), "utf-8"),
|
|
meta({ subject: "Your Friday morning order with Uber Eats", sender: "uber.com", receivedAt: "2026-01-09T09:26:44Z" })
|
|
);
|
|
expect(p.totals.total_charged).toBeCloseTo(16.50, 2);
|
|
expect(p.payment.credits_amount).toBeCloseTo(1.17, 2);
|
|
expect(p.payment.card_amount).toBeCloseTo(15.33, 2);
|
|
expect(p.payment.card_last4).toBe("8032");
|
|
expect(validateOrderTotals(p).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Uber route (pick-up / delivery)", () => {
|
|
const uber = (f: string, subject = "Your Wednesday order with Uber Eats") =>
|
|
parseOrderHTML(html(f), meta({ subject, sender: "uber.com" }));
|
|
|
|
it("reads both stops with their times, as printed", () => {
|
|
const p = uber("ue-00");
|
|
expect(p.route).toEqual([
|
|
{ label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" },
|
|
{ label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" },
|
|
]);
|
|
});
|
|
|
|
it("de-duplicates the block Uber renders twice", () => {
|
|
// The receipt emits the whole address section a second time for narrow
|
|
// screens. Without de-duplication every trip has four stops.
|
|
expect(uber("ue-00").route).toHaveLength(2);
|
|
expect(uber("ue-26").route).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps the receipt's own wording rather than normalising it", () => {
|
|
// Uber is not internally consistent: "Pick-up" on some receipts,
|
|
// "Pickup" on others. Inventing a canonical spelling would hide that a
|
|
// template changed.
|
|
expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup");
|
|
});
|
|
|
|
it("works on an international receipt", () => {
|
|
const p = uber("ue-26");
|
|
expect(p.route[1].address).toContain("Luzern, Switzerland");
|
|
});
|
|
|
|
it("DoorDash has no route — its receipts carry no addresses", () => {
|
|
expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("Uber line items", () => {
|
|
it("itemises a grocery order, binding qty/title/amount by item id", () => {
|
|
const p = parseOrderHTML(
|
|
html("ue-09"),
|
|
meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
|
);
|
|
expect(p.line_items).toHaveLength(5);
|
|
expect(p.line_items[0]).toMatchObject({
|
|
qty: 1,
|
|
description: "Highland Brewing MILK FULL CREAM U H T 900ML",
|
|
amount: 440,
|
|
});
|
|
// A sold-out item prints 0.00 and is kept: it is why the total is lower
|
|
// than what was ordered, and dropping it makes the receipt unexplainable.
|
|
expect(p.line_items.map((i) => i.amount)).toContain(0);
|
|
});
|
|
|
|
it("a restaurant order legitimately has none", () => {
|
|
// Uber itemises groceries only; a restaurant receipt states a total and
|
|
// nothing else. Empty here is the receipt, not a parse failure — so it
|
|
// must not raise no_line_items_parsed either.
|
|
const p = parseOrderHTML(
|
|
html("ue-00"),
|
|
meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
|
);
|
|
expect(p.line_items).toEqual([]);
|
|
expect(p.flags).not.toContain("no_line_items_parsed");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the
|
|
* user pointed out that only *overseas* rides go on a card — local rides are
|
|
* paid with credits, which puts them in the same class as delivery orders.
|
|
*/
|
|
describe("Uber trips", () => {
|
|
const utMeta: Record<string, MessageMeta> = JSON.parse(
|
|
readFileSync(resolve(dir, "ut-meta.json"), "utf-8")
|
|
);
|
|
const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]);
|
|
|
|
it("a local trip is credits-funded", () => {
|
|
const p = trip("ut-00");
|
|
expect(p.platform).toBe("uber");
|
|
expect(p.currency).toBe("AUD");
|
|
expect(p.totals.total_charged).toBeCloseTo(84.78, 2);
|
|
expect(p.payment.credits_amount).toBeCloseTo(84.78, 2);
|
|
expect(p.payment.card_last4).toBeNull();
|
|
expect(validateOrderTotals(p).ok).toBe(true);
|
|
});
|
|
|
|
it("an overseas trip is card-settled", () => {
|
|
const p = trip("ut-01");
|
|
expect(p.currency).toBe("NZD");
|
|
expect(p.totals.total_charged).toBeCloseTo(55.51, 2);
|
|
expect(p.payment.card_amount).toBeCloseTo(55.51, 2);
|
|
expect(p.payment.card_last4).toBe("3893");
|
|
});
|
|
|
|
it("labels the two ends of a trip, which the receipt does not", () => {
|
|
// Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time
|
|
// alone. The naive split put the time in `label` and left `time` null.
|
|
const p = trip("ut-00");
|
|
expect(p.route).toEqual([
|
|
{
|
|
label: "Pick-up",
|
|
time: "7:32 pm",
|
|
address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia",
|
|
},
|
|
{
|
|
label: "Drop-off",
|
|
time: "8:10 pm",
|
|
address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects the charge summary Uber sends before the receipt", () => {
|
|
// Uber sends two mails per trip with the same subject and the same total.
|
|
// The first says "This is not a payment receipt" and carries no
|
|
// tripReference, so order_reference would fall back to msg:<id> and I7
|
|
// could not dedupe it — every trip would be recorded twice.
|
|
expect(() => trip("ut-summary")).toThrow(NotAReceiptError);
|
|
});
|
|
});
|
|
|
|
describe("orderDescription", () => {
|
|
it("names the platform", () => {
|
|
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)");
|
|
expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
|
|
"Order - Coles (Wyndham Vale) (Uber Eats)"
|
|
);
|
|
});
|
|
|
|
it("does not restate a platform the merchant already names", () => {
|
|
// A trip's merchant is literally "Uber Trip"; "(Uber)" after it says
|
|
// nothing. What identifies a trip is its addresses, and those live in the
|
|
// Order details panel.
|
|
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
|
|
});
|
|
});
|