Files
HealthLog/src/__tests__/list-cap-reader-guard.test.ts
T
Marc-André Bombeck dc5120b59a feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
2026-10-20 10:04:11 +02:00

118 lines
4.0 KiB
TypeScript

/**
* Structural guard: every list the server caps has a client that reads the
* cap.
*
* A capped list says so on the envelope (`apiListSuccess`, `meta.truncated`
* / `meta.limit`). The web client read only `data` (`apiGet`), so the signal
* reached nobody: twelve routes sent it and no surface showed it. A capped
* route is now bound to a reader, a production file outside the API tree
* that fetches the route's path through `apiGetList` / `apiPostList`, the
* one client reader of the cap, which a surface pairs with `ListCapNotice`.
*
* The routes are derived from the tree (every route file that answers with
* `apiListSuccess`), never listed by hand, so a newly capped route fails here
* until something reads its cap. Comments are stripped first; the planted
* cases prove the matchers fire.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** Capped routes with no list on screen, and why. */
const NO_LIST_ON_SCREEN: Record<string, string> = {
"/api/personal-records":
"The web reads it for one decorative badge (a record set in the last 30 days), not as a list; the native client owns the list.",
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** `app/api/a/[id]/b/route.ts` → `/api/a/[id]/b`. */
function routePathOf(file: string): string {
return `/${file.replace(/^app\//, "").replace(/\/route\.tsx?$/, "")}`;
}
/** A matcher for the route's path as a client spells it. */
function clientPathPattern(route: string): RegExp {
const body = route
.split("/")
.map((seg) =>
/^\[.+\]$/.test(seg)
? "\\$\\{[^}]+\\}"
: seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
)
.join("/");
return new RegExp(
`\\bapi(?:Get|Post)List\\s*<[\\s\\S]{0,200}?>\\s*\\(\\s*["\`]${body}(?:[?"\`])|\\bapi(?:Get|Post)List\\s*\\(\\s*["\`]${body}(?:[?"\`])`,
);
}
describe("capped lists have a reader", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
const read = (file: string) =>
stripComments(readFileSync(join(SRC, file), "utf8"));
const cappedRoutes = files
.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f))
.filter((f) => /\bapiListSuccess\s*\(/.test(read(f)))
.map(routePathOf)
.sort();
const clients = files
.filter((f) => !f.startsWith("app/api/"))
.map((f) => read(f));
it("the matcher binds a client spelling to its route", () => {
expect(
clientPathPattern("/api/medications/[id]/side-effects").test(
"apiGetList<{ items: Row[] }>(`/api/medications/${medicationId}/side-effects?from=1`)",
),
).toBe(true);
expect(
clientPathPattern("/api/allergies").test(
'apiGetList<AllergyDTO[]>("/api/allergies")',
),
).toBe(true);
// A plain `apiGet` reads `data` only and does not count.
expect(
clientPathPattern("/api/allergies").test(
'apiGet<AllergyDTO[]>("/api/allergies")',
),
).toBe(false);
// A longer path that shares the prefix does not count either.
expect(
clientPathPattern("/api/allergies").test(
'apiGetList<AllergyDTO>("/api/allergies/x")',
),
).toBe(false);
});
it("finds the capped routes", () => {
expect(cappedRoutes.length).toBeGreaterThanOrEqual(12);
});
it("binds every capped route to a client that reads its cap", () => {
const unread = cappedRoutes.filter(
(route) =>
!(route in NO_LIST_ON_SCREEN) &&
!clients.some((code) => clientPathPattern(route).test(code)),
);
expect(unread).toEqual([]);
});
it("keeps the exemptions to routes that are still capped", () => {
for (const route of Object.keys(NO_LIST_ON_SCREEN)) {
expect(cappedRoutes).toContain(route);
}
});
});