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); }); });