mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(guards): make path-pinned source reads fail when their subject moves
A test that reads a source file by path and asserts an absence
(`expect(source).not.toMatch(…)`) stays green after a split: the code it
watched moved next door and the old path no longer holds it. A missing file
throws; a file that kept its name but lost its contents does not.
- `readSource(path, anchors)` in helpers/source-files.ts reads one file and
requires every anchor (a string or pattern the watched code carries) to
match at least once; it throws on a missing file, a lost anchor, or a read
without an anchor.
- `readSourceFolder(dir, { floor })` is the folder-scan form for a subject
that was split into several files.
- The twelve reads that were asserted only negatively now go through
`readSource` with an anchor: the batch route, the integration-sync
registrar, the dashboard client, the mood insights client, the sleep
overview and sleep page, the sidebar, the baseline form, the account
section (two guards), api-handler's requireAuth and the Google Health sync.
- The two schema parsers the wipe and enum guards share now refuse a parse
of the real schema that finds fewer than 50 models (and 10 enums), so a
moved file or a drifted block regex cannot leave those guards comparing
an allowlist against nothing. Synthetic schemas a test writes stay exempt.
Proof: renaming `setSaving` in the baseline form made the converted
confirm-failure guard throw "the file no longer contains setSaving"; the
helper's own test plants a missing file, a lost string anchor, a lost regex
anchor, an empty anchor list and an under-floor folder, each of which throws.
This commit is contained in:
1 parent
c86c7d1a79
commit
f3d7ab31e2
16 files changed
+204
-44
No files matched your search
@@ -48,6 +48,7 @@ import {
|
||||
convertHkValue,
|
||||
isConvertibleHkUnit,
|
||||
} from "@/lib/measurements/hk-units";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
|
||||
|
||||
@@ -193,10 +194,10 @@ describe("Apple Health mapping — the record's own unit (issue #944)", () => {
|
||||
"utf8",
|
||||
);
|
||||
expect(archive).toContain(optIn);
|
||||
const batch = readFileSync(
|
||||
join(process.cwd(), "src/app/api/measurements/batch/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const batch = readSource("src/app/api/measurements/batch/route.ts", [
|
||||
"export const POST",
|
||||
"hkIdentifier",
|
||||
]);
|
||||
expect(
|
||||
batch,
|
||||
"the batch route opted into record-unit conversion",
|
||||
|
||||
@@ -171,5 +171,16 @@ export function readDeclaredSchema(
|
||||
};
|
||||
});
|
||||
|
||||
// Every consumer compares this against an allowlist or a "none of these"
|
||||
// rule, which an empty parse satisfies. For the real schema, this little is
|
||||
// a broken read (a moved file, a block regex that stopped matching), not a
|
||||
// schema. A synthetic schema a test writes itself is exempt.
|
||||
if (schemaPath === SCHEMA_PATH && (models.length < 50 || enums.length < 10)) {
|
||||
throw new Error(
|
||||
`readDeclaredSchema(${schemaPath}): parsed ${models.length} model(s) and ` +
|
||||
`${enums.length} enum(s), below the floor of 50 and 10. Fix the parser ` +
|
||||
`before trusting a guard built on it.`,
|
||||
);
|
||||
}
|
||||
return { enums, models };
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
* worktrees, each a full second copy of the tree that would double every
|
||||
* match. Every caller roots at `src/`, `e2e/` or a subtree of one.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
export function walkSourceFiles(
|
||||
@@ -229,3 +229,82 @@ export function scanSourceMatches(
|
||||
options.matchFloor,
|
||||
) as SourceMatch[];
|
||||
}
|
||||
|
||||
/** Something a file must still contain for a read of it to mean anything. */
|
||||
export type SourceAnchor = string | RegExp;
|
||||
|
||||
function anchorCount(text: string, anchor: SourceAnchor): number {
|
||||
if (typeof anchor === "string") return text.split(anchor).length - 1;
|
||||
const flags = anchor.flags.includes("g") ? anchor.flags : `${anchor.flags}g`;
|
||||
return [...text.matchAll(new RegExp(anchor.source, flags))].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one source file by path, and prove it is still the file the guard is
|
||||
* about.
|
||||
*
|
||||
* A guard that reads a fixed path and asserts that something is ABSENT
|
||||
* (`expect(source).not.toMatch(…)`) is green on a file that has been split:
|
||||
* the code it watched moved next door, the old path holds whatever is left,
|
||||
* and the absence is true for the wrong reason. A missing file at least
|
||||
* throws; a file that kept its name but lost its contents does not.
|
||||
*
|
||||
* So the caller names at least one anchor: a string or pattern the watched
|
||||
* code itself carries (the function the guard is about, the call it pins).
|
||||
* Every anchor must match at least once, or this throws and names the file,
|
||||
* which is the moment to point the guard at the new home (or at a folder
|
||||
* with `readSourceFolder`). `rel` is relative to the repository root.
|
||||
*/
|
||||
export function readSource(
|
||||
rel: string,
|
||||
anchors: SourceAnchor | readonly SourceAnchor[],
|
||||
): string {
|
||||
const list = (Array.isArray(anchors) ? anchors : [anchors]) as SourceAnchor[];
|
||||
if (list.length === 0) {
|
||||
throw new Error(
|
||||
`readSource(${rel}): name at least one anchor the watched code carries.`,
|
||||
);
|
||||
}
|
||||
const path = join(process.cwd(), rel);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
`readSource(${rel}): the file does not exist. If it was split or moved, ` +
|
||||
`point the guard at the new location or scan the folder with ` +
|
||||
`readSourceFolder and a floor.`,
|
||||
);
|
||||
}
|
||||
const text = readFileSync(path, "utf8");
|
||||
const lost = list.filter((anchor) => anchorCount(text, anchor) === 0);
|
||||
if (lost.length > 0) {
|
||||
throw new Error(
|
||||
`readSource(${rel}): the file no longer contains ${lost
|
||||
.map(String)
|
||||
.join(", ")}. The code this guard watches has moved; a negative ` +
|
||||
`assertion on this file would now pass for the wrong reason.`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every source file under a folder (relative to the repository root),
|
||||
* with a floor on how many it found: the folder-scan form of `readSource`
|
||||
* for a guard whose subject was split into several files. Returns
|
||||
* `[path relative to the folder, text]` pairs, sorted.
|
||||
*/
|
||||
export function readSourceFolder(
|
||||
relDir: string,
|
||||
options: {
|
||||
floor: number;
|
||||
include?: (rel: string) => boolean;
|
||||
extensions?: readonly string[];
|
||||
},
|
||||
): Array<[string, string]> {
|
||||
const root = join(process.cwd(), relDir);
|
||||
const files = walkSourceFiles(root, {
|
||||
floor: options.floor,
|
||||
extensions: options.extensions,
|
||||
}).filter(options.include ?? (() => true));
|
||||
requireFloor(`readSourceFolder(${relDir})`, files, options.floor);
|
||||
return files.map((rel) => [rel, readFileSync(join(root, rel), "utf8")]);
|
||||
}
|
||||
@@ -38,6 +38,12 @@ export function parseWipeSchema(
|
||||
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
|
||||
(m) => ({ name: m[1], body: m[2] }),
|
||||
);
|
||||
if (schemaPath === WIPE_SCHEMA_PATH && blocks.length < 50) {
|
||||
throw new Error(
|
||||
`parseWipeSchema(${schemaPath}): parsed ${blocks.length} model(s), below ` +
|
||||
`the floor of 50. A wipe guard over an empty parse passes on nothing.`,
|
||||
);
|
||||
}
|
||||
const modelNames = new Set(blocks.map((b) => b.name));
|
||||
|
||||
const out = new Map<string, ModelShape>();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict";
|
||||
import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
@@ -65,9 +66,9 @@ describe("moodLog integration stays removed", () => {
|
||||
});
|
||||
|
||||
it("registers no moodlog queue, cron or handler binding", () => {
|
||||
const registrar = readFileSync(
|
||||
path.join(ROOT, "src/lib/jobs/reminder/register-integration-sync.ts"),
|
||||
"utf8",
|
||||
const registrar = readSource(
|
||||
"src/lib/jobs/reminder/register-integration-sync.ts",
|
||||
"export async function registerIntegrationSyncQueues",
|
||||
);
|
||||
expect(registrar).not.toContain("moodlog");
|
||||
expect(registrar).not.toContain("MoodLog");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `readSource` and `readSourceFolder`: the reads a path-pinned guard goes
|
||||
* through, so a split file fails the guard instead of leaving it green.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource, readSourceFolder } from "./helpers/source-files";
|
||||
|
||||
const HERE = "src/__tests__/helpers/source-files.ts";
|
||||
|
||||
describe("readSource", () => {
|
||||
it("returns the text when every anchor is present", () => {
|
||||
const text = readSource(HERE, [
|
||||
"export function readSource(",
|
||||
/export function walkSourceFiles\(/,
|
||||
]);
|
||||
expect(text.length).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("throws on a file that does not exist", () => {
|
||||
expect(() => readSource("src/lib/does-not-exist.ts", "anything")).toThrow(
|
||||
/does not exist/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when an anchor no longer matches", () => {
|
||||
expect(() =>
|
||||
readSource(HERE, ["export function readSource(", "noSuchAnchorHere"]),
|
||||
).toThrow(/no longer contains noSuchAnchorHere/);
|
||||
expect(() => readSource(HERE, /export function neverDeclared\(/)).toThrow(
|
||||
/no longer contains/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a read without an anchor", () => {
|
||||
expect(() => readSource(HERE, [])).toThrow(/at least one anchor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSourceFolder", () => {
|
||||
it("returns the files with their text, above the floor", () => {
|
||||
const files = readSourceFolder("src/__tests__/helpers", { floor: 5 });
|
||||
expect(files.length).toBeGreaterThanOrEqual(5);
|
||||
expect(files.map(([rel]) => rel)).toContain("source-files.ts");
|
||||
});
|
||||
|
||||
it("throws when the folder holds fewer files than the floor", () => {
|
||||
expect(() =>
|
||||
readSourceFolder("src/__tests__/helpers", {
|
||||
floor: 1,
|
||||
include: (rel) => rel === "nothing-matches.ts",
|
||||
}),
|
||||
).toThrow(/below the stated floor/);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
/**
|
||||
* The dashboard HRV tile must accept either HRV type.
|
||||
@@ -250,10 +251,10 @@ describe("the tile the user actually sees", () => {
|
||||
// current formatting of the replacement, so reformatting cannot break
|
||||
// it. Freshness is per-type and captioning a live RMSSD tile from the
|
||||
// SDNN lookup is the failure it guards.
|
||||
const page = readFileSync(
|
||||
join(process.cwd(), "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const page = readSource("src/app/page-client.tsx", [
|
||||
"const tileStaleDays",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
]);
|
||||
expect(page).not.toContain('tileStaleDays("HEART_RATE_VARIABILITY")');
|
||||
expect(page).not.toContain(
|
||||
"const hrvSummary = data?.summaries?.HEART_RATE_VARIABILITY",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { DELETE } from "../[id]/route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const USER = { id: "user-1", username: "u" };
|
||||
|
||||
@@ -168,11 +169,9 @@ describe("the switch and the authentication path", () => {
|
||||
// a token would NOT be live during the outage and hiding the list would
|
||||
// stop being wrong. This reads the source because the behaviour is an
|
||||
// absence, and an absence has no call to spy on.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/lib/api-handler.ts"),
|
||||
"utf8",
|
||||
const source = readSource(
|
||||
"src/lib/api-handler.ts",
|
||||
"export async function requireAuth(",
|
||||
);
|
||||
expect(source).not.toContain("isApiGloballyEnabled");
|
||||
expect(source).not.toContain("apiGlobal");
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const INSIGHTS = join(ROOT, "src/app/insights");
|
||||
@@ -52,7 +53,10 @@ describe("Insights add actions", () => {
|
||||
});
|
||||
|
||||
it("the mood page has no text links into logging or the check-in", () => {
|
||||
const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8");
|
||||
const source = readSource(
|
||||
"src/app/insights/mood/page-client.tsx",
|
||||
"<SubPageAddButton",
|
||||
);
|
||||
expect(source).not.toMatch(/<Link\s+href="\/mood"\s+data-slot/);
|
||||
expect(source).not.toContain('href="/mental-wellbeing"');
|
||||
expect(source).not.toContain("insights.mood.mentalWellbeingLink");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSettingsSectionSlug } from "@/lib/settings/section-slugs";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
describe("sleep empty-state CTA href", () => {
|
||||
it("links to /settings/integrations (a real settings slug)", () => {
|
||||
@@ -29,9 +30,9 @@ describe("sleep empty-state CTA href", () => {
|
||||
});
|
||||
|
||||
it("never links to the removed /settings/data-sources slug", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/sleep/page.tsx"),
|
||||
"utf8",
|
||||
const source = readSource(
|
||||
"src/app/insights/sleep/page.tsx",
|
||||
"<MetricEmptyState",
|
||||
);
|
||||
expect(source).not.toContain("/settings/data-sources");
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSettingsSectionSlug } from "@/lib/settings/section-slugs";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
describe("sleep-overview empty-state CTA href", () => {
|
||||
it("links to /settings/integrations (a real settings slug)", () => {
|
||||
@@ -30,9 +31,9 @@ describe("sleep-overview empty-state CTA href", () => {
|
||||
});
|
||||
|
||||
it("never links to the non-existent /settings/devices slug", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/insights/sleep-overview.tsx"),
|
||||
"utf8",
|
||||
const source = readSource(
|
||||
"src/components/insights/sleep-overview.tsx",
|
||||
"<EmptyState",
|
||||
);
|
||||
expect(source).not.toContain("/settings/devices");
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||
import { visibleUtilityDestinations } from "../nav-model";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
function render({
|
||||
pathname = "/",
|
||||
@@ -565,9 +566,9 @@ describe("<SidebarNav> carries no rules for a viewport it never shows on", () =>
|
||||
// A touch screen at most 520 px tall gets the phone shell (the
|
||||
// `shell-mobile` variant), so the rail is hidden there and a class keyed
|
||||
// on that query can never apply.
|
||||
const source = readFileSync(
|
||||
join(__dirname, "..", "sidebar-nav.tsx"),
|
||||
"utf8",
|
||||
const source = readSource(
|
||||
"src/components/layout/sidebar-nav.tsx",
|
||||
"export function SidebarNav",
|
||||
);
|
||||
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* component tests render server-side and cannot click.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
@@ -27,6 +25,7 @@ import {
|
||||
|
||||
import { runBaselineAttempt } from "../baseline-form";
|
||||
import { runConfirmFinish } from "../confirm-screen";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
type PassableStep = Parameters<
|
||||
Parameters<typeof runConfirmFinish>[0]["passStep"]
|
||||
@@ -92,10 +91,10 @@ describe("runBaselineAttempt", () => {
|
||||
it("is the form's only way to move the pending flag", () => {
|
||||
// A second, inline `setSaving(false)` is how the flag stopped being
|
||||
// reset on one of the three exits in the first place.
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/onboarding/baseline-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const source = readSource("src/components/onboarding/baseline-form.tsx", [
|
||||
"export function BaselineForm",
|
||||
"setSaving",
|
||||
]);
|
||||
expect(source).not.toContain("setSaving(false)");
|
||||
expect(source).not.toContain("setSaving(true)");
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AnamnesisSection } from "../anamnesis-section";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
/**
|
||||
* #159 — the "About me" note and the free-text allergies line moved from the
|
||||
@@ -144,9 +143,9 @@ describe("Anamnese — the About-me note card (#159)", () => {
|
||||
|
||||
describe("Account settings — the About-me card is gone (#159)", () => {
|
||||
it("the account section carries no about-me mount any more", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
const source = readSource(
|
||||
"src/components/settings/account-section/index.tsx",
|
||||
"export function AccountSection",
|
||||
);
|
||||
expect(source).not.toContain("AboutMeSection");
|
||||
expect(source).not.toContain("about-me-section");
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
import { PasswordCard } from "../password-card";
|
||||
import { SecuritySection } from "../security-section";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const SLOT = 'data-slot="settings-password-card"';
|
||||
|
||||
@@ -108,9 +109,9 @@ describe("change-password card placement", () => {
|
||||
});
|
||||
|
||||
it("left no password form behind in the Account section", () => {
|
||||
const account = readFileSync(
|
||||
join(ROOT, "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
const account = readSource(
|
||||
"src/components/settings/account-section/index.tsx",
|
||||
"export function AccountSection",
|
||||
);
|
||||
for (const marker of [
|
||||
"settings.passwordTitle",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
type Provider = "google_health" | "withings";
|
||||
type UserStatus = "complete" | "partial" | "failed" | "parked" | "skipped";
|
||||
@@ -45,9 +46,9 @@ const googleHandlerSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/reminder/google-health-sync.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const googleSyncSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/google-health/sync.ts"),
|
||||
"utf8",
|
||||
const googleSyncSource = readSource(
|
||||
"src/lib/google-health/sync.ts",
|
||||
"export async function syncUserGoogleHealth(",
|
||||
);
|
||||
const withingsHandlerSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/reminder/withings-sync.ts"),
|
||||
|
||||
Reference in new issue
Block a user