CI ran lint with continue-on-error because 23 errors predated it. All are fixed, so the flag is gone and a typecheck step is added beside it — nothing else in CI compiles the test files, so a type error in a fixture could only ever be found on a developer's machine. Real fixes, no blanket disables and no suppressions: - react-hooks/set-state-in-effect (6): each was "reset or seed state when an input changes", so each becomes a state adjustment during render behind a previous-value guard — the documented React pattern for it. Two were also latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter() every render, so the effect already ran every render, one commit late. shared/page.tsx depended on the `tagIds` array identity, so the row window was reset more often than "changing any filter" claims — including after "Show more", the one interaction it must not undo; the key now joins by value. csv-import-modal reads localStorage once at mount, which is a lazy useState initializer, not an effect (SSR-guarded, though the modal is mounted from client state and never prerenders). - react-hooks/static-components (3): SortIcon was declared inside SharedPage, making it a new component type on every render. Hoisted to module scope with the sort state passed as props. - @typescript-eslint/no-explicit-any (9): the dynamically imported route handlers in two integration tests. Typed as their real signatures with one `as unknown as NextRequest` cast at the fixture boundary, so the call sites and response bodies get checked instead of opted out. - react/no-unescaped-entities (5): exact-equivalent entities (', "), so no rendered text changes. Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511): `options` is optional, and `?? 0` keeps the assertion failing on an absent list rather than silencing it with a non-null bang. 9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on warnings, so lint blocks on errors only — deliberately, since none of the remaining warnings should stop a push.
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
|
import type { NextRequest } from "next/server";
|
|
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);
|
|
|
|
/** What the review route returns, to the extent these tests read it. */
|
|
type ItemVerdict = { item: string; verdict: string };
|
|
type Review = {
|
|
participant_id: number;
|
|
rating: string | null;
|
|
note: string | null;
|
|
order_again: boolean | null;
|
|
item_verdicts: ItemVerdict[];
|
|
};
|
|
type ReviewBody = {
|
|
reviews: Review[];
|
|
splits: { participant_id: number; share_percent: number }[];
|
|
merchant: {
|
|
warn: boolean;
|
|
note: string | null;
|
|
counts: Record<string, number>;
|
|
history: { transaction_id: number }[];
|
|
items: { item: string; loved: number; never: number }[];
|
|
} | null;
|
|
};
|
|
type Ctx = { params: Promise<{ id: string }> };
|
|
|
|
// Declared rather than inferred because the route is imported dynamically,
|
|
// after vi.doMock. `unknown` for the request keeps the fixture honest — it is
|
|
// the two properties the handler reads, not a NextRequest.
|
|
let GET: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
|
let PUT: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
|
let ownerId: number;
|
|
let otherId: number;
|
|
let txnId: number;
|
|
|
|
const req = (body?: unknown) =>
|
|
({ headers: { get: () => null }, json: async () => body }) as unknown as NextRequest;
|
|
const params = (id: number): Ctx => ({ params: Promise.resolve({ id: String(id) }) });
|
|
const bodyOf = async (res: Response): Promise<ReviewBody> => (await res.json()) as ReviewBody;
|
|
|
|
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 bodyOf(res);
|
|
|
|
// 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) => r.participant_id === ownerId)?.rating).toBe("loved");
|
|
expect(body.reviews.find((r) => 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 bodyOf(res);
|
|
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 bodyOf(res);
|
|
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 bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
|
|
expect(body.reviews[0].order_again).toBe(false);
|
|
body = await bodyOf(await PUT(req({ rating: "ok" }), params(txnId)));
|
|
expect(body.reviews[0].order_again).toBe(true);
|
|
// "bad" is also a no — you would not choose it again.
|
|
body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
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 bodyOf(res);
|
|
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 bodyOf(res);
|
|
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 bodyOf(res);
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
expect(body.merchant!.warn).toBe(true);
|
|
expect(body.merchant!.counts.never).toBe(1);
|
|
expect(body.merchant!.history.map((h) => 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 bodyOf(await GET(req(), params(txnId)));
|
|
expect(body.merchant!.warn).toBe(true);
|
|
expect(body.merchant!.history.map((h) => 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 bodyOf(await GET(req(), params(txnId)));
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
// 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 bodyOf(await GET(req(), params(txnId)));
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
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 bodyOf(await GET(req(), params(txnId)));
|
|
expect(body.splits).toHaveLength(1);
|
|
expect(body.splits[0].participant_id).toBe(otherId);
|
|
});
|
|
});
|