Files
HealthLog/src/__tests__/manage-activity-inventory.test.ts
T
Rob de Graaf c6f61fad33 feat(i18n): add Dutch as an eighth end-to-end locale
Adds messages/nl.json (AI-initial, informal je/jij register, full key
parity with en.json) and registers nl everywhere the locale list lives:
config, loaders, Intl/date-fns mapping, zod enums, OpenAPI spec, scripts,
the managed-profile picker and the notification locale guards.

Dutch follows the Korean pattern for the AI surfaces (English body plus a
Dutch reply-language footer, no safety-contract matrix) and gets its own
banks in the outbound safety screen (dose, risk, causal, continuation),
the prose-grounding and citation-coverage tables, the reference-range
prose and the no-key fallback copy, each with Dutch test fixtures.

PHQ-9 / GAD-7 / WHO-5 are not marked validated for nl yet: the Dutch
wording has not been read against the published forms, so Dutch readers
get the English items plus the existing note until it has.
2026-10-19 11:06:55 +02:00

691 lines
28 KiB
TypeScript

import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { revokeBody } from "@/components/settings/access/grants-given-card";
import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
/**
* Every MANAGE-admitted action names itself, in all six locales.
*
* ## What this asks that the sibling guard does not
*
* `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the
* same question of the WRITE surface: a delegable write whose audit action has
* no sentence renders as "made a change". MANAGE is where that answer stops
* being merely vague and starts being wrong. A WRITE delegate can only add,
* and "somebody added something" is at least the right shape of fact. A MANAGE
* delegate can change and remove entries the owner made themselves, so the
* generic line would tell an owner that something happened in their record
* while withholding the only part they need — whether a reading was corrected
* or deleted, whether a medication was stopped, whether the dose history was
* purged. The level ships under "reconstructable or refused", and a feed that
* cannot say which verb ran does not reconstruct anything.
*
* So the inventory here is the MANAGE half of the same sweep, and it goes two
* steps further than its sibling:
*
* 1. it resolves the sentence by CALLING the map rather than by matching
* `case "…"` in its source, because a `case` inside a comment matches and
* an action whose arm falls through to the fallback does not;
* 2. it follows the resolved key into all six bundles, because a key that
* exists only in English renders as its own dotted path to everyone else.
*
* ## PHI
*
* Two legs, and both are about the same failure. The activity feed reads out
* of the audit table, the audit table is not encrypted, and the temptation
* whenever a sentence feels thin is to put the value in it — "{name} changed
* your weight from 81 to 78". That is a health figure in a second store with
* its own retention and no rotation story. So: every verb line interpolates
* `name` and nothing else, in every locale, and the feed's own DTO carries no
* `details` field for a future caller to reach for.
*
* ## Why the counts are of MATCHES
*
* The verdict is an empty list of unmapped actions, and an empty match set
* says exactly the same thing. Every leg therefore pins the size of what it
* matched — modules walked, MANAGE modules found, one of them named, actions
* read, sentences resolved — so a matcher that quietly stopped matching fails
* instead of passing. `describe("the matcher sees what it looks for")` proves
* both halves against synthetic modules, including the one shape that silenced
* the sibling guard: a resolver call prettier broke across lines.
*/
const SRC = join(process.cwd(), "src");
const API = join(SRC, "app/api");
const MESSAGES = join(process.cwd(), "messages");
const ACTIVITY_ROUTE = join(
process.cwd(),
"src/app/api/account/activity/route.ts",
);
const GRANTS_GIVEN_CARD = join(
process.cwd(),
"src/components/settings/access/grants-given-card.tsx",
);
/** The declaration a record-delegated route makes, at any level. */
const RECORD_RESOLVER = "requireRecordAuth";
/**
* Rows the feed cannot show, and the reason each one cannot.
*
* `src/lib/api-handler.ts` is a direct import of every route, so the walk
* below reaches the three actions the auth kit files itself. None of them can
* appear in a record owner's activity view, and the reason is the same for all
* three: they are filed under `auth.user.id` — the CALLER — at a point where
* no record has been resolved. The feed selects rows where `userId` is the
* owner and `actorUserId` is somebody else, so a caller-filed row is excluded
* twice over.
*
* Frozen at three. A fourth entry is a claim about a row nobody looked at, and
* the leg below fails rather than letting one join quietly.
*/
const NOT_IN_THE_FEED = [
// The bearer path never resolves a record at all.
"auth.bearer.failure",
// Step-up is cookie-only and filed under the account being elevated.
"auth.stepup.elevation.rejected",
// `auditRefusal` files under the refused caller, before a record exists.
"sharing.access.denied",
] as const;
/** What an action with no sentence of its own renders as. */
const FALLBACK_KEY = "recordSharing.activity.acted";
/** The six bundles the product ships. */
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
* `*` skips a leading dot and this tree has dot-directories. */
function routeFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...routeFiles(full));
else if (entry.name === "route.ts") out.push(full);
}
return out;
}
interface ModuleFacts {
/** Does the module reach the record resolver at ANY level. */
delegatesRecord: boolean;
/**
* Every level the module passes to `requireRecordAuth`, in source order.
*
* Recorded so the inclusion rule's LEVEL axis has something that can
* falsify it. `delegatesRecord` used to be set only for `"manage"`, and the
* legs below could not tell the corrected rule from the old one: every
* assertion about coverage passed either way, because a manage-only sweep
* still finds every manage action. The leg that separates them asks whether
* the walked set STRICTLY EXCEEDS the manage-only one, and it needs the
* levels to ask.
*/
recordLevels: string[];
/**
* Every action the module hands to `auditLog("…")`, awaited or not.
*
* v1.37.0 — this used to keep only the AWAITED calls, on the reasoning that
* a fire-and-forget breadcrumb is not worth narrating. The feed disagrees,
* and the feed is what the owner reads: `/api/account/activity` selects on
* `actorUserId IS NOT NULL` with no action filter and no await in sight, so
* a `void auditLog(…)` breadcrumb lands in the view exactly like an awaited
* row. Ten `*.validation-failed` actions were invisible to this guard for
* that reason and rendered to owners as "made a change in your record" —
* including one a READ delegate can trigger. A guard whose inclusion rule
* differs from its subject's proves a property about the wrong set.
*/
auditActions: string[];
/** First-party modules this one imports, resolved to files. */
imports: string[];
}
/**
* Read both facts off one parse.
*
* AST rather than substrings, for the reason the sibling guard learned by
* going green while matching nothing: prettier moves a call's argument onto
* its own line the moment the call grows a second one, and
* `requireRecordAuth("manage"` stops appearing in the source text without
* anything about the route changing. The import table resolves a local name
* back to its exported one, so an alias and a namespace-qualified call both
* count, and a comment naming either symbol counts for nothing.
*/
export function moduleFacts(source: string, fileName: string): ModuleFacts {
const parsed = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const aliases = new Map<string, string>();
const namespaces = new Set<string>();
const importPaths: string[] = [];
parsed.forEachChild((node) => {
if (!ts.isImportDeclaration(node)) return;
if (ts.isStringLiteral(node.moduleSpecifier)) {
const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName);
if (resolved !== null && !resolved.includes("__tests__")) {
importPaths.push(resolved);
}
}
const bindings = node.importClause?.namedBindings;
if (!bindings) return;
if (ts.isNamespaceImport(bindings)) {
namespaces.add(bindings.name.text);
return;
}
for (const spec of bindings.elements) {
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
}
});
/** The original exported name behind this callee, or null. */
const calleeName = (callee: ts.Expression): string | null => {
if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
if (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.expression) &&
namespaces.has(callee.expression.text)
) {
return callee.name.text;
}
return null;
};
/** The first argument when it is a plain string literal. */
const firstStringArg = (node: ts.CallExpression): string | null => {
const first = node.arguments[0];
return first && ts.isStringLiteralLike(first) ? first.text : null;
};
let delegatesRecord = false;
const recordLevels: string[] = [];
const auditActions: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const name = calleeName(node.expression);
if (name === RECORD_RESOLVER) {
delegatesRecord = true;
const level = firstStringArg(node);
if (level !== null) recordLevels.push(level);
}
if (name === "auditLog") {
const action = firstStringArg(node);
if (action !== null) auditActions.push(action);
}
}
node.forEachChild(visit);
};
parsed.forEachChild(visit);
return { delegatesRecord, recordLevels, auditActions, imports: importPaths };
}
/**
* A module specifier resolved to a first-party file, or null.
*
* `@/…` and relative paths only. A package import is somebody else's tree and
* cannot be walked usefully.
*/
export function resolveFirstParty(
specifier: string,
fromFile: string,
): string | null {
let base: string;
if (specifier.startsWith("@/")) base = join(SRC, specifier.slice(2));
else if (specifier.startsWith(".")) base = join(dirname(fromFile), specifier);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
]) {
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* Every action one route module can file, including through what it imports.
*
* One level deep, deliberately. The transitive closure pulls in the whole auth
* and grant machinery through `api-handler`, which reports actions no request
* to this route can file; one level is where a route's own collaborators live
* — the import-admission module and the medication lifecycle module are both
* direct imports of the routes that use them, and both were invisible to a
* walk that read only `route.ts`.
*/
function actionsReachableFrom(
file: string,
read: (path: string) => ModuleFacts,
): string[] {
const own = read(file);
const out = [...own.auditActions];
for (const imported of own.imports) {
out.push(...read(imported).auditActions);
}
return out;
}
/** The message key one action resolves to, or the fallback. */
function verbKeyFor(action: string): { key: string; params: string[] } {
let key = "";
let params: string[] = [];
recordActivityVerbLine(
(resolved, given) => {
key = resolved;
params = Object.keys(given ?? {});
return resolved;
},
action,
"Alex",
);
return { key, params };
}
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
/** The leaf at a dotted path, or undefined. */
function leaf(root: Record<string, unknown>, key: string): unknown {
let node: unknown = root;
for (const segment of key.split(".")) {
if (typeof node !== "object" || node === null) return undefined;
node = (node as Record<string, unknown>)[segment];
}
return node;
}
/** The `{placeholders}` a translated sentence interpolates. */
function placeholders(sentence: string): string[] {
return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
}
describe("the matcher sees what it looks for", () => {
const REFORMATTED = `
import { requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
export const DELETE = apiHandler(async () => {
const { user } = await requireRecordAuth(
"manage",
"labs",
);
await auditLog("thing.delete", { userId: user.id });
return apiSuccess({ id: user.id });
});
`;
it("matches a resolver call broken across lines", () => {
// The shape that silenced the sibling guard: the argument moves onto its
// own line and a substring test goes quiet with nothing else changed.
expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
const facts = moduleFacts(REFORMATTED, "r.ts");
expect(facts.delegatesRecord).toBe(true);
expect(facts.auditActions).toEqual(["thing.delete"]);
});
it("matches an aliased and a namespace-qualified resolver call", () => {
const ALIASED = `
import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
import { auditLog as record } from "@/lib/auth/audit";
export const DELETE = apiHandler(async () => {
const { user } = await resolveRecord("manage", "labs");
await record("thing.aliased");
return apiSuccess({ id: user.id });
});
`;
const facts = moduleFacts(ALIASED, "a.ts");
expect(facts.delegatesRecord).toBe(true);
expect(facts.auditActions).toEqual(["thing.aliased"]);
const NAMESPACED = `
import * as auth from "@/lib/api-handler";
export const DELETE = auth.apiHandler(async () => {
const { user } = await auth.requireRecordAuth("manage", "labs");
return apiSuccess({ id: user.id });
});
`;
expect(moduleFacts(NAMESPACED, "n.ts").delegatesRecord).toBe(true);
});
it("does not enrol a route that only talks about delegating", () => {
const ACTOR_ONLY = `
/** An actor surface. It must never take requireRecordAuth("manage"). */
import { requireActorAuth } from "@/lib/api-handler";
export const POST = apiHandler(async () => {
const { user } = await requireActorAuth();
return apiSuccess({ id: user.id });
});
`;
// The raw text carries the exact substring a naive membership test uses.
expect(ACTOR_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
expect(moduleFacts(ACTOR_ONLY, "ao.ts").delegatesRecord).toBe(false);
});
it("reads the fire-and-forget audit calls too", () => {
// The defect this file shipped with. `void auditLog(…)` was invisible
// here and perfectly visible in the owner's feed, so ten refusal
// breadcrumbs rendered as "made a change in your record".
const MIXED = `
import { requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
export const DELETE = apiHandler(async () => {
const { user } = await requireRecordAuth("manage", "labs");
void auditLog("thing.breadcrumb", {});
await auditLog("thing.delete", { userId: user.id });
return apiSuccess({ id: user.id });
});
`;
expect(moduleFacts(MIXED, "m.ts").auditActions).toEqual([
"thing.breadcrumb",
"thing.delete",
]);
});
it("resolves a first-party import and skips a package one", () => {
// The other half of the same defect: two of the twelve unmapped actions
// were filed by library modules the routes import, which a walk over
// `route.ts` alone could not see.
const admission = resolveFirstParty(
"@/lib/medications/intake-import-admission",
join(API, "medications/route.ts"),
);
expect(admission).not.toBeNull();
expect(readFileSync(admission!, "utf8")).toContain(
"medication.intake.import.kickoff.denied",
);
expect(resolveFirstParty("next/server", "x.ts")).toBeNull();
expect(resolveFirstParty("@/lib/nothing-here", "x.ts")).toBeNull();
});
it("still reports the fallback for an action nobody mapped", () => {
// The positive control for the recording translator below. Without it, a
// `verbKeyFor` that always returned the same string would report every
// action as specific and this file would prove nothing.
expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY);
});
});
describe("every delegated action has a sentence of its own", () => {
const routes = routeFiles(API);
const cache = new Map<string, ModuleFacts>();
const read = (file: string): ModuleFacts => {
let facts = cache.get(file);
if (!facts) {
facts = moduleFacts(readFileSync(file, "utf8"), file);
cache.set(file, facts);
}
return facts;
};
const delegatedModules = routes.filter((file) => read(file).delegatesRecord);
const excluded = new Set<string>(NOT_IN_THE_FEED);
const reachable = [
...new Set(
delegatedModules.flatMap((file) => actionsReachableFrom(file, read)),
),
].sort();
const actions = reachable.filter((action) => !excluded.has(action));
it("matches the delegated modules, and not zero of them", () => {
// A walk that finds nothing agrees with an inventory that covers nothing.
expect(routes.length).toBeGreaterThan(300);
expect(delegatedModules.length).toBeGreaterThan(0);
// Named anchors, so a matcher that collapsed to one stray file fails
// rather than clearing the floor. Deleting a reading and purging a dose
// history are the two acts MANAGE exists for; the mood list is the READ
// route whose refusal breadcrumb reaches the feed, and it is here because
// the level is no longer part of the inclusion rule.
const found = delegatedModules.map((file) => relative(process.cwd(), file));
expect(found).toContain("src/app/api/measurements/[id]/route.ts");
expect(found).toContain(
"src/app/api/medications/[id]/intake/purge/route.ts",
);
expect(found).toContain("src/app/api/mood-entries/route.ts");
// And the actions themselves were read, not merely looked for.
expect(actions.length).toBeGreaterThan(60);
});
it("walks strictly more modules than a manage-only sweep would", () => {
// The level axis of the inclusion rule, with something that can falsify
// it. Every other leg in this file passes under BOTH rules — a manage-only
// sweep still finds every manage action, so coverage cannot tell the two
// apart. This one can: the corrected rule is "any module that reaches
// `requireRecordAuth` at any level", so the walked set must strictly
// exceed the subset that names `manage` anywhere.
const manageOnly = delegatedModules.filter((file) =>
read(file).recordLevels.includes("manage"),
);
expect(manageOnly.length).toBeGreaterThan(0);
expect(delegatedModules.length).toBeGreaterThan(manageOnly.length);
// A named anchor, so the claim does not rest on a count that could drift
// to equality without anybody noticing. The custom-metrics list resolves
// `requireRecordAuth("read", …)` and nothing else, so a manage-only filter
// drops it — along with the refusal breadcrumbs it and its neighbours file.
const anchor = join(API, "custom-metrics/route.ts");
expect(read(anchor).recordLevels).toEqual(["read"]);
expect(read(anchor).recordLevels).not.toContain("manage");
expect(delegatedModules).toContain(anchor);
expect(manageOnly).not.toContain(anchor);
});
it("reaches the actions a route files through what it imports", () => {
// Both halves of the fix, pinned against the two that were invisible.
expect(actions).toContain("medication.intake.import.kickoff.denied");
expect(actions).toContain("medication.oneShot.reconciled");
// And the fire-and-forget half, on the READ route that started this.
expect(actions).toContain("mood-entries.list.validation-failed");
});
it("excludes exactly the three rows the feed cannot show", () => {
// The exclusion list is the one place a real action could be hidden, so
// its size is pinned and every member is asserted to have been REACHED —
// an entry naming an action nothing files would be a claim about nothing.
expect(NOT_IN_THE_FEED).toHaveLength(3);
for (const action of NOT_IN_THE_FEED) {
expect(reachable, action).toContain(action);
}
});
it("gives every one of them a specific verb rather than the fallback", () => {
const generic = actions.filter(
(action) => verbKeyFor(action).key === FALLBACK_KEY,
);
expect(
generic,
"a delegated action with no verb line renders to the owner as the " +
"generic 'made a change'. Add a case to activity-verb.ts and a " +
"sentence to all six bundles — or, if the row genuinely should not " +
"reach the feed, stop stamping the actor on it.",
).toEqual([]);
});
it("never reports a refused attempt as something that happened", () => {
// The half of the HIGH finding that a coverage count cannot express. Every
// one of these rows is an attempt that changed NOTHING, and the generic
// line told the owner their record had been edited. A future arm that maps
// `…validation-failed` onto an ordinary verb would be the same defect with
// coverage, so the shape of the key is asserted rather than its presence.
const attempts = actions.filter(
(action) =>
action.endsWith(".validation-failed") || action.endsWith(".denied"),
);
expect(attempts.length).toBeGreaterThan(10);
const dishonest = attempts.filter(
(action) => !/\.(rejected|denied)[A-Z]/.test(verbKeyFor(action).key),
);
expect(
dishonest,
"an attempt that was refused must not render as an act that happened",
).toEqual([]);
// And the sentences themselves say so, in the source language. A key named
// `rejected…` whose EN text read "added a reading" would pass the check
// above and be exactly the lie it exists to stop.
const en = bundle("en");
for (const action of attempts) {
const sentence = leaf(en, verbKeyFor(action).key);
expect(typeof sentence, action).toBe("string");
expect(String(sentence), action).toMatch(
/tried to|could not|was refused|search this record/,
);
}
});
it("resolves every verb key in all six bundles", () => {
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
const missing: string[] = [];
let resolved = 0;
for (const action of actions) {
const { key } = verbKeyFor(action);
for (const locale of LOCALES) {
const value = leaf(bundles.get(locale)!, key);
if (typeof value === "string" && value.length > 0) resolved += 1;
else missing.push(`${locale}: ${key}`);
}
}
expect(missing.sort()).toEqual([]);
// Six locales for every action, and the actions were counted above.
expect(resolved).toBe(actions.length * LOCALES.length);
});
it("interpolates the actor's name and nothing else, in every locale", () => {
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
const leaky: string[] = [];
let checked = 0;
for (const action of actions) {
const { key, params } = verbKeyFor(action);
// The call site's own parameters first: a sentence cannot leak a value
// the map never hands it.
if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`);
for (const locale of LOCALES) {
const value = leaf(bundles.get(locale)!, key);
if (typeof value !== "string") continue;
checked += 1;
const found = placeholders(value);
if (found.length !== 1 || found[0] !== "name") {
leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`);
}
}
}
expect(leaky.sort()).toEqual([]);
expect(checked).toBe(actions.length * LOCALES.length);
});
});
describe("the retention disclosure does not depend on a race", () => {
/**
* The sentence that bounds attribution — "who entered what is answerable for
* the next N days" — used to appear only if a SECOND query had already
* answered. The revoke dialog reads the grant list; the number came off the
* activity feed. Two independent requests, and whichever lost decided
* whether the owner was told. An owner who clicked quickly, or whose
* activity read failed, ended a manage grant having read one sentence fewer
* than an owner who clicked slowly, with nothing on screen to indicate that
* a sentence was missing.
*
* The number now rides the same payload as the row: if there is a grant to
* revoke, `retentionDays` is already in hand. Deterministic means there is
* no branch left, which is what both legs below check from opposite sides —
* one that the sentence is always composed, one that the second query is
* gone.
*/
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${JSON.stringify(params)})` : key;
it("states the window on every grant that could have written something", () => {
for (const access of ["WRITE", "MANAGE"] as const) {
const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 });
expect(body).toContain("recordSharing.given.revokeEntriesStay");
expect(body).toContain(
'recordSharing.given.revokeAttribution({"days":90})',
);
}
// READ keeps neither: a read grant never put anything in the record, so
// saying what happens to what they entered would describe an act that
// cannot have occurred.
const read = revokeBody({
t,
access: "READ",
name: "Alex",
retentionDays: 90,
});
expect(read).not.toContain("recordSharing.given.revokeAttribution");
});
it("takes the number from the read that produced the row", () => {
const source = readFileSync(GRANTS_GIVEN_CARD, "utf8");
// Positive control: the matcher can see a hook name in this file at all.
expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0);
// And the second query, whose arrival used to decide the copy, is gone.
expect(source).not.toContain("useRecordActivity");
// The branch it fed is gone with it. A required number cannot be absent,
// so there is nothing left for a caller to omit — which is the difference
// between "the sentence is usually there" and "there is no path without
// it". Both halves are named: the optional marker and the guard.
expect(source).not.toContain("retentionDays?:");
expect(source).not.toContain("retentionDays !== undefined");
expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1);
});
});
describe("the activity feed carries no audit detail", () => {
/**
* The DTO the owner's feed publishes, read off the route.
*
* A field-level assertion rather than a sentence in a comment, because the
* pressure to widen it is real and constant: every thin sentence in the feed
* looks like it wants the value that would make it specific, and the audit
* row usually has one. `details` is a free-form JSON column written by sixty
* handlers; publishing it would put whatever any of them decided to file in
* front of the record owner, and some of them file a label.
*/
it("publishes five fields, and `details` is not one of them", () => {
const source = readFileSync(ACTIVITY_ROUTE, "utf8");
const parsed = ts.createSourceFile(
ACTIVITY_ROUTE,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
let members: string[] | null = null;
parsed.forEachChild((node) => {
if (!ts.isInterfaceDeclaration(node)) return;
if (node.name.text !== "RecordActivityEntry") return;
members = node.members
.map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : ""))
.filter((n) => n.length > 0)
.sort();
});
// The interface was found at all. `toEqual` against null would report the
// absence as a mismatch of the list, which reads like the wrong failure.
expect(
members,
"RecordActivityEntry not found in the route",
).not.toBeNull();
expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]);
});
});