Files
finance-app/src/__tests__/integration/order-reviews.test.ts
T
siddharthd d458228625
ci / lint-test (push) Successful in 47s
feat(orders): a fifth verdict, 'bad', between ok and never again
The jump from "OK" to "Never again" is too big and most
disappointments live in the gap (user, 2026-07-28) — so a merely poor
meal either flattered itself as OK or got blacklisted.

Only 'never' raises the warning on a future order. A blacklist that fires
for every mediocre delivery is one nobody reads, so 'bad' records the
disappointment without triggering the alarm. Both set order_again = false
— you would not choose either again — and that split between "would I
order it" and "warn me about it" is the point of the extra level.

Migration widens the CHECK; nothing is removed, so no existing row needs
mapping.
2026-07-28 19:12:26 +10:00

241 lines
9.6 KiB
TypeScript

import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { createPool, mockDbWithPool, resetDB } from "./helpers";
/**
* Verdicts on orders — the "never order from here again" memory (ING-9).
*
* These cover the two things that are easy to get silently wrong and invisible
* on screen when you do: a second person's verdict overwriting the first, and a
* note-only save wiping the per-item opinions. Both are the same shape as the
* bug that reset `settled` on split rewrites.
*/
const pool = createPool();
mockDbWithPool(pool);
let GET: any;
let PUT: any;
let ownerId: number;
let otherId: number;
let txnId: number;
const req = (body?: unknown) =>
({ headers: { get: () => null }, json: async () => body }) as any;
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
beforeAll(async () => {
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
}));
vi.doMock("@/lib/queries", () => ({ canAccessTransactions: async () => true }));
({ GET, PUT } = await import("../../app/api/transactions/[id]/review/route"));
});
/** An ingested order: a transaction plus the expense_metadata behind it. */
async function seedOrder(merchant: string, date = "2026-07-01") {
const t = await pool.query(
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, merchant_name, owner_id)
VALUES ($1, $2, 48.20, 'debit', $3, $4) RETURNING id`,
[date, `DoorDash ${merchant}`, merchant, ownerId]
);
const id = t.rows[0].id as number;
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items)
VALUES ($1, 'email', $2, $3, '[]'::jsonb)`,
[id, `ref-${id}`, merchant]
);
return id;
}
beforeEach(async () => {
await resetDB(pool);
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
ownerId = a.rows[0].id;
otherId = b.rows[0].id;
txnId = await seedOrder("Thai Palace");
});
describe("order verdicts — one per person", () => {
it("keeps both people's verdicts on the same order", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId));
const body = await res.json();
// The bug this guards: a UNIQUE on transaction_id alone made the second
// save overwrite the first, and the disagreement is the useful part.
expect(body.reviews).toHaveLength(2);
expect(body.reviews.find((r: any) => r.participant_id === ownerId).rating).toBe("loved");
expect(body.reviews.find((r: any) => r.participant_id === otherId).rating).toBe("never");
});
it("defaults the verdict to the signed-in user, not the owner", async () => {
const res = await PUT(req({ rating: "ok" }), params(txnId));
const body = await res.json();
expect(body.reviews[0].participant_id).toBe(ownerId);
});
it("revising a verdict updates rather than duplicating", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
const body = await res.json();
expect(body.reviews).toHaveLength(1);
expect(body.reviews[0].rating).toBe("never");
});
it("rejects a rating outside the scale", async () => {
const res = await PUT(req({ rating: "amazing" }), params(txnId));
expect(res.status).toBe(400);
});
it("derives order_again from the rating", async () => {
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json();
expect(body.reviews[0].order_again).toBe(false);
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
expect(body.reviews[0].order_again).toBe(true);
// "bad" is also a no — you would not choose it again.
body = await (await PUT(req({ rating: "bad" }), params(txnId))).json();
expect(body.reviews[0].order_again).toBe(false);
});
it("only `never` raises the warning, not `bad`", async () => {
// The boundary the five-level scale exists for. A blacklist that fires for
// every mediocre meal is one nobody reads, so "bad" records the
// disappointment without triggering the alarm.
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
let body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.counts.bad).toBe(1);
expect(body.merchant.warn).toBe(false);
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(true);
});
});
describe("item verdicts", () => {
it("a note-only save does not wipe item opinions", async () => {
await PUT(
req({
rating: "liked",
item_verdicts: [{ item: "Pad Thai", verdict: "loved" }],
}),
params(txnId)
);
// No item_verdicts key at all — the shape a note-only form sends.
const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId));
const body = await res.json();
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
]);
expect(body.reviews[0].note).toBe("slow delivery");
});
it("an explicit empty array does clear them", async () => {
await PUT(
req({ rating: "liked", item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
params(txnId)
);
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
const body = await res.json();
expect(body.reviews[0].item_verdicts).toEqual([]);
});
it("drops malformed entries without failing the save", async () => {
const res = await PUT(
req({
rating: "ok",
item_verdicts: [
{ item: "Pad Thai", verdict: "loved" },
{ item: "", verdict: "loved" },
{ item: "Curry", verdict: "middling" },
],
}),
params(txnId)
);
const body = await res.json();
expect(body.reviews[0].rating).toBe("ok");
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
]);
});
});
describe("merchant history", () => {
it("warns when the merchant was ever marked never, and excludes this order", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older));
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(true);
expect(body.merchant.counts.never).toBe(1);
expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]);
});
it("treats the same restaurant as one merchant across platforms", async () => {
// DoorDash and Uber Eats capitalise differently — "TEG Kebabs & Biryani"
// vs "TEG KEBABS & BIRYANI". An exact match split one restaurant's history
// in two, so a "never again" recorded through one app never warned in the
// other, silently defeating the point of the memory.
const shouty = await seedOrder("THAI PALACE", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty));
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(true);
expect(body.merchant.history.map((h: any) => h.transaction_id)).toContain(shouty);
});
it("does not carry a verdict across different merchants", async () => {
const other = await seedOrder("Pizza Place", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(false);
});
it("pools item opinions across the merchant's orders", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(
req({ item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
params(older)
);
const older2 = await seedOrder("Thai Palace", "2026-05-01");
await PUT(
req({ item_verdicts: [{ item: "pad thai", verdict: "loved" }] }),
params(older2)
);
const body = await (await GET(req(), params(txnId))).json();
// Case-folded: the same dish comes back capitalised differently between
// receipts, and two entries for one dish is not a track record.
expect(body.merchant.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
});
it("keeps item opinions from reviews that have no overall rating", async () => {
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(
req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }),
params(older)
);
const body = await (await GET(req(), params(txnId))).json();
expect(body.merchant.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
});
});
describe("share state", () => {
it("reports splits so the panel can show shared vs just me", async () => {
let body = await (await GET(req(), params(txnId))).json();
expect(body.splits).toEqual([]);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txnId, otherId]
);
body = await (await GET(req(), params(txnId))).json();
expect(body.splits).toHaveLength(1);
expect(body.splits[0].participant_id).toBe(otherId);
});
});