chore(ci): clear the lint debt and make lint blocking (#79)

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.
This commit is contained in:
2026-08-21 00:52:14 +10:00
parent 9f1be74330
commit fc9ffaba25
12 changed files with 193 additions and 86 deletions
+62 -35
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import type { NextRequest } from "next/server";
import { createPool, mockDbWithPool, resetDB } from "./helpers";
/**
@@ -12,15 +13,41 @@ import { createPool, mockDbWithPool, resetDB } from "./helpers";
const pool = createPool();
mockDbWithPool(pool);
let GET: any;
let PUT: any;
/** 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 any;
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
({ 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", () => ({
@@ -59,25 +86,25 @@ 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();
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: any) => r.participant_id === ownerId).rating).toBe("loved");
expect(body.reviews.find((r: any) => r.participant_id === otherId).rating).toBe("never");
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 res.json();
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 res.json();
const body = await bodyOf(res);
expect(body.reviews).toHaveLength(1);
expect(body.reviews[0].rating).toBe("never");
});
@@ -88,12 +115,12 @@ describe("order verdicts — one per person", () => {
});
it("derives order_again from the rating", async () => {
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json();
let body = await bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false);
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
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 (await PUT(req({ rating: "bad" }), params(txnId))).json();
body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false);
});
@@ -104,13 +131,13 @@ describe("order verdicts — one per person", () => {
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);
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 (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(true);
body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.warn).toBe(true);
});
});
@@ -125,7 +152,7 @@ describe("item verdicts", () => {
);
// 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();
const body = await bodyOf(res);
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
]);
@@ -138,7 +165,7 @@ describe("item verdicts", () => {
params(txnId)
);
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
const body = await res.json();
const body = await bodyOf(res);
expect(body.reviews[0].item_verdicts).toEqual([]);
});
@@ -154,7 +181,7 @@ describe("item verdicts", () => {
}),
params(txnId)
);
const body = await res.json();
const body = await bodyOf(res);
expect(body.reviews[0].rating).toBe("ok");
expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" },
@@ -167,10 +194,10 @@ describe("merchant history", () => {
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]);
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 () => {
@@ -181,17 +208,17 @@ describe("merchant history", () => {
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);
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 (await GET(req(), params(txnId))).json();
expect(body.merchant.warn).toBe(false);
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 () => {
@@ -206,10 +233,10 @@ describe("merchant history", () => {
params(older2)
);
const body = await (await GET(req(), params(txnId))).json();
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 }]);
expect(body.merchant!.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
});
it("keeps item opinions from reviews that have no overall rating", async () => {
@@ -218,14 +245,14 @@ describe("merchant history", () => {
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 }]);
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 (await GET(req(), params(txnId))).json();
let body = await bodyOf(await GET(req(), params(txnId)));
expect(body.splits).toEqual([]);
await pool.query(
@@ -233,7 +260,7 @@ describe("share state", () => {
VALUES ($1, $2, 50)`,
[txnId, otherId]
);
body = await (await GET(req(), params(txnId))).json();
body = await bodyOf(await GET(req(), params(txnId)));
expect(body.splits).toHaveLength(1);
expect(body.splits[0].participant_id).toBe(otherId);
});