mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
queryKeys,
|
|
measurementDependentKeys,
|
|
moodDependentKeys,
|
|
medicationDependentKeys,
|
|
invalidateKeys,
|
|
refetchInactiveDailyReads,
|
|
} from "../query-keys";
|
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
|
|
describe("queryKeys factory", () => {
|
|
it("returns stable tuples for simple keys", () => {
|
|
expect(queryKeys.measurements()).toEqual(["measurements"]);
|
|
expect(queryKeys.analytics()).toEqual(["analytics"]);
|
|
expect(queryKeys.moodEntries()).toEqual(["mood-entries"]);
|
|
});
|
|
|
|
it("includes locale in insights status keys", () => {
|
|
expect(queryKeys.insightsBpStatus("en")).toEqual([
|
|
"insights",
|
|
"blood-pressure-status",
|
|
"en",
|
|
]);
|
|
});
|
|
|
|
// v1.4.33 IW2 — slice param routes the slim consumer onto its own
|
|
// cache slot but leaves `["analytics"]` (the root) as a prefix so
|
|
// mutation invalidations sweep both shapes.
|
|
it("exposes the unified dashboard-snapshot key", () => {
|
|
expect(queryKeys.dashboardSnapshot()).toEqual(["dashboard", "snapshot"]);
|
|
});
|
|
|
|
// v1.21.3 (b) — the snapshot key takes an optional `locale` segment so a
|
|
// locale switch reads freshly-localised prose on its own cell. The zero-arg
|
|
// call MUST stay byte-identical to the legacy literal so every prefix-based
|
|
// invalidation bundle + zero-arg reader keeps prefix-matching the new cells.
|
|
it("threads an optional locale segment through dashboardSnapshot", () => {
|
|
expect(queryKeys.dashboardSnapshot("en")).toEqual([
|
|
"dashboard",
|
|
"snapshot",
|
|
"en",
|
|
]);
|
|
expect(queryKeys.dashboardSnapshot("de")).toEqual([
|
|
"dashboard",
|
|
"snapshot",
|
|
"de",
|
|
]);
|
|
});
|
|
|
|
it("keeps the zero-arg dashboardSnapshot call byte-identical to the legacy literal", () => {
|
|
expect(queryKeys.dashboardSnapshot()).toEqual(["dashboard", "snapshot"]);
|
|
expect(queryKeys.dashboardSnapshot(undefined)).toEqual([
|
|
"dashboard",
|
|
"snapshot",
|
|
]);
|
|
});
|
|
|
|
it("makes a zero-arg dashboardSnapshot key a prefix of a locale-keyed cell", () => {
|
|
// TanStack `invalidateQueries({ queryKey })` matches by prefix, so a
|
|
// zero-arg invalidate must reach every locale-keyed snapshot cell.
|
|
const zeroArg = queryKeys.dashboardSnapshot();
|
|
const localeKeyed = queryKeys.dashboardSnapshot("en");
|
|
expect(localeKeyed.slice(0, zeroArg.length)).toEqual(zeroArg);
|
|
});
|
|
|
|
it("threads the slim slice through queryKeys.analytics", () => {
|
|
expect(queryKeys.analytics("summaries")).toEqual([
|
|
"analytics",
|
|
"summaries",
|
|
]);
|
|
});
|
|
|
|
it("keeps the no-arg call byte-identical to the legacy literal", () => {
|
|
expect(queryKeys.analytics()).toEqual(["analytics"]);
|
|
expect(queryKeys.analytics(undefined)).toEqual(["analytics"]);
|
|
});
|
|
|
|
// v1.4.40 W-RSC — the four new factory entries from this wave each
|
|
// need a byte-stable contract so the audit-cited drift cases stay
|
|
// pinned. A future rename of any of these literals would land a
|
|
// matching test failure rather than silently breaking cache layout.
|
|
it("exposes the authMe shape so useAuth doesn't drift back to a bare literal", () => {
|
|
expect(queryKeys.authMe()).toEqual(["auth", "me"]);
|
|
});
|
|
|
|
it("exposes the userThresholds shape for Settings + targets", () => {
|
|
expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]);
|
|
});
|
|
|
|
// v1.4.41 W-FRONTEND-FACTORY — pin the shapes that auth/login + the
|
|
// notifications surface migrated to so a future rename can't drift
|
|
// the cache layout silently.
|
|
it("exposes auth + notifications keys for the factory-migrated surfaces", () => {
|
|
expect(queryKeys.authRegistrationStatus()).toEqual([
|
|
"auth",
|
|
"registration-status",
|
|
]);
|
|
expect(queryKeys.notificationsPreferences()).toEqual([
|
|
"notifications",
|
|
"preferences",
|
|
]);
|
|
expect(queryKeys.notificationsStatus()).toEqual([
|
|
"notifications",
|
|
"status",
|
|
]);
|
|
expect(queryKeys.authNotificationPrefs()).toEqual([
|
|
"auth",
|
|
"me",
|
|
"notification-prefs",
|
|
]);
|
|
expect(queryKeys.apiVersion()).toEqual(["api", "version"]);
|
|
});
|
|
|
|
it("packs chartData params into a stable tuple prefixed by chart-data", () => {
|
|
const key = queryKeys.chartData(
|
|
"WEIGHT",
|
|
"raw",
|
|
"no-bmi",
|
|
"Europe/Berlin",
|
|
"2026-04-01T00:00:00.000Z",
|
|
"2026-05-01T00:00:00.000Z",
|
|
);
|
|
expect(key[0]).toBe("chart-data");
|
|
// v1.7.0 — the tuple trails with the display `valueScale` (default 1).
|
|
expect(key.length).toBe(8);
|
|
expect(key[7]).toBe(1);
|
|
expect(
|
|
queryKeys.chartData(
|
|
"WALKING_SPEED",
|
|
"raw",
|
|
"no-bmi",
|
|
"Europe/Berlin",
|
|
"2026-04-01T00:00:00.000Z",
|
|
"2026-05-01T00:00:00.000Z",
|
|
3.6,
|
|
)[7],
|
|
).toBe(3.6);
|
|
// Numeric bmiDivisor is allowed (BMI charts pass `heightCm * heightCm`).
|
|
const bmiKey = queryKeys.chartData(
|
|
"WEIGHT",
|
|
"bmi",
|
|
30625,
|
|
"Europe/Berlin",
|
|
"2026-04-01T00:00:00.000Z",
|
|
"2026-05-01T00:00:00.000Z",
|
|
);
|
|
expect(bmiKey[3]).toBe(30625);
|
|
});
|
|
|
|
it("prefixes the dashboard medication-compliance key under one root", () => {
|
|
expect(queryKeys.dashboardMedicationCompliance(7)).toEqual([
|
|
"dashboard-medication-compliance",
|
|
7,
|
|
]);
|
|
expect(queryKeys.dashboardMedicationCompliance(30)[0]).toBe(
|
|
"dashboard-medication-compliance",
|
|
);
|
|
});
|
|
|
|
// v1.4.42 W3-QUERYKEY-LONGTAIL — pin the shapes of the new factory
|
|
// entries so a future rename can't drift the cache layout silently.
|
|
it("exposes the per-medication sub-keys under the medications prefix", () => {
|
|
expect(queryKeys.medicationCompliance("med1")).toEqual([
|
|
"medications",
|
|
"med1",
|
|
"compliance",
|
|
]);
|
|
expect(queryKeys.medicationCadence("med1")).toEqual([
|
|
"medications",
|
|
"med1",
|
|
"cadence",
|
|
]);
|
|
expect(queryKeys.medicationGlp1Details("med1")).toEqual([
|
|
"medications",
|
|
"med1",
|
|
"glp1-details",
|
|
]);
|
|
expect(queryKeys.medicationIntakeDrugLevelChart("med1")).toEqual([
|
|
"medications",
|
|
"med1",
|
|
"intake",
|
|
"drug-level-chart",
|
|
]);
|
|
const listKey = queryKeys.medicationIntakeList("med1", {
|
|
sortBy: "takenAt",
|
|
sortDir: "desc",
|
|
limit: 25,
|
|
offset: 0,
|
|
status: "completed",
|
|
});
|
|
expect(listKey).toEqual([
|
|
"medications",
|
|
"med1",
|
|
"intake",
|
|
"list",
|
|
"takenAt",
|
|
"desc",
|
|
25,
|
|
0,
|
|
"completed",
|
|
]);
|
|
});
|
|
|
|
it("exposes withingsStatus under the withings prefix", () => {
|
|
expect(queryKeys.withingsStatus()).toEqual(["withings", "status"]);
|
|
});
|
|
|
|
it("packs adminAuditLogFiltered params into a stable filtered shape", () => {
|
|
const key = queryKeys.adminAuditLogFiltered({
|
|
filter: "all",
|
|
page: 1,
|
|
perPage: 50,
|
|
actor: "",
|
|
actionFilter: "",
|
|
target: "",
|
|
range: "7d",
|
|
});
|
|
expect(key[0]).toBe("admin");
|
|
expect(key[1]).toBe("audit-log");
|
|
expect(key[2]).toBe("filtered");
|
|
expect(key.length).toBe(10);
|
|
});
|
|
|
|
it("packs workoutsRecentList opts under the workouts-recent prefix", () => {
|
|
const key = queryKeys.workoutsRecentList({ limit: 3 });
|
|
expect(key[0]).toBe("workouts");
|
|
expect(key[1]).toBe("recent");
|
|
});
|
|
|
|
it("exposes a per-metric custom-entry prefix for broad invalidation", () => {
|
|
expect(queryKeys.customMetricEntriesPrefix("metric-1")).toEqual([
|
|
"custom-metric-entries",
|
|
"metric-1",
|
|
]);
|
|
});
|
|
|
|
it("exposes the nutrients prefix for broad invalidation", () => {
|
|
expect(queryKeys.nutrientsRoot()).toEqual(["nutrients"]);
|
|
});
|
|
});
|
|
|
|
describe("dependent-key bundles", () => {
|
|
it("measurementDependentKeys invalidates analytics/insights/targets", () => {
|
|
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["measurements"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["insights", "targets"]));
|
|
expect(keyStrings).toContain(
|
|
JSON.stringify(["gamification", "achievements"]),
|
|
);
|
|
});
|
|
|
|
// v1.4.40 W-RSC — chart-data prefix now lives in the bundle so a
|
|
// fresh measurement evicts every per-chart daily-aggregate cache.
|
|
// Pre-fix the chart row stayed 60 s stale after a measurement save
|
|
// (audit-C2). Pin the bundle membership so a refactor of the chart
|
|
// queryKey factory can't silently drop the dependency.
|
|
it("measurementDependentKeys bundles the chart-data prefix (v1.4.40)", () => {
|
|
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["chart-data"]));
|
|
});
|
|
|
|
// v1.18.9 (#38) — the dashboard snapshot must ride the measurement
|
|
// bundle too. The hero band / score ring / tile strip read ONE snapshot
|
|
// query with refetch-on-mount/focus options that otherwise leave an
|
|
// in-app-added reading invisible on the Startseite until the 120 s poll
|
|
// or a hard reload. Mirror the v1.16.11 medication-bundle fix.
|
|
it("measurementDependentKeys bundles the dashboard snapshot (v1.18.9)", () => {
|
|
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
|
});
|
|
|
|
// v1.29.x — a manual measurement write never invalidated the Today
|
|
// digest, so its score / rail items lingered stale after a save from
|
|
// the values list or the dashboard quick-entry sheet. Mirror the
|
|
// v1.29.1 medication fix.
|
|
it("measurementDependentKeys bundles the daily digest", () => {
|
|
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["daily", "digest"]));
|
|
});
|
|
|
|
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
|
|
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["mood-analytics"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
|
});
|
|
|
|
// v1.28.42 (M2) — the dashboard snapshot embeds a mood block + feeds the
|
|
// score ring, but a mood write only invalidated the mood bundle, leaving the
|
|
// Startseite tile / score stale for up to ~120 s. Mirror the measurement
|
|
// (v1.18.9) and medication (v1.16.11) bundles. Pin the membership so a future
|
|
// refactor can't silently drop the dependency again.
|
|
it("moodDependentKeys bundles the dashboard snapshot (v1.28.42)", () => {
|
|
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
|
});
|
|
|
|
// v1.29.x — mirrors the measurement fix: a manual mood write never
|
|
// invalidated the Today digest.
|
|
it("moodDependentKeys bundles the daily digest", () => {
|
|
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["daily", "digest"]));
|
|
});
|
|
|
|
it("medicationDependentKeys bundle covers medications + analytics + achievements", () => {
|
|
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["medications"]));
|
|
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
|
expect(keyStrings).toContain(
|
|
JSON.stringify(["gamification", "achievements"]),
|
|
);
|
|
});
|
|
|
|
// v1.4.40 W-RSC — dashboard-medication-compliance prefix now lives in
|
|
// the bundle so an intake POST refreshes the dashboard chart in
|
|
// lockstep with the per-medication compliance-chart-inline tile
|
|
// (audit-L4).
|
|
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
|
|
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(
|
|
JSON.stringify(["dashboard-medication-compliance"]),
|
|
);
|
|
});
|
|
|
|
// v1.5.5 D-3 §10 invariant 20 — the per-medication inline compliance
|
|
// chart cache key (`["compliance-chart-inline", medicationId]`) needs
|
|
// its prefix in the bundle so every detail-page mutation evicts the
|
|
// tile in one tick. Hierarchical prefix-match catches every per-id
|
|
// slot beneath it.
|
|
it("medicationDependentKeys bundles the compliance-chart-inline prefix (v1.5.5)", () => {
|
|
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["compliance-chart-inline"]));
|
|
});
|
|
|
|
// v1.16.11 — the dashboard snapshot feeds the hero band, dose tally,
|
|
// verdict and checklist from ONE query with refetch-on-mount/focus
|
|
// off and a 120 s poll. Without this key in the bundle a dose taken
|
|
// from the dashboard stayed visibly due until the next poll (#316).
|
|
it("medicationDependentKeys bundles the dashboard snapshot (v1.16.11)", () => {
|
|
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
|
});
|
|
|
|
// v1.29.1 — the Today digest reads `medsToday` from the same server
|
|
// snapshot the intake routes hard-evict, but nothing invalidated the client
|
|
// query, so the digest's dose-window rail item lingered until a hard reload.
|
|
it("medicationDependentKeys bundles the daily digest (v1.29.1)", () => {
|
|
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
|
expect(keyStrings).toContain(JSON.stringify(["daily", "digest"]));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Factory-bypass source guard.
|
|
*
|
|
* Components and hooks are client-facing by convention. App and lib modules
|
|
* join the guarded surface when they declare `"use client"`. Tests and the
|
|
* factory definition directory are excluded because they intentionally build
|
|
* literal fixtures and key tuples.
|
|
*/
|
|
describe("queryKey factory enforcement", () => {
|
|
const repoRoot = join(__dirname, "..", "..", "..");
|
|
const sourceRoots = [
|
|
join(repoRoot, "src", "components"),
|
|
join(repoRoot, "src", "hooks"),
|
|
join(repoRoot, "src", "app"),
|
|
join(repoRoot, "src", "lib"),
|
|
];
|
|
|
|
function listFiles(dir: string): string[] {
|
|
const entries = readdirSync(dir);
|
|
const out: string[] = [];
|
|
for (const name of entries) {
|
|
const full = join(dir, name);
|
|
const st = statSync(full);
|
|
if (st.isDirectory()) {
|
|
if (
|
|
name === "__tests__" ||
|
|
name === "__mocks__" ||
|
|
name === "node_modules"
|
|
) {
|
|
continue;
|
|
}
|
|
out.push(...listFiles(full));
|
|
} else if (
|
|
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
|
!/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(name)
|
|
) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isClientSource(file: string, text: string): boolean {
|
|
const relative = file.slice(repoRoot.length + 1).replaceAll("\\", "/");
|
|
if (
|
|
relative.startsWith("src/components/") ||
|
|
relative.startsWith("src/hooks/")
|
|
) {
|
|
return true;
|
|
}
|
|
return /^\s*["']use client["'];/.test(text);
|
|
}
|
|
|
|
it("no client module declares a bare-literal TanStack key", () => {
|
|
const offenders: Array<{ file: string; line: number; snippet: string }> =
|
|
[];
|
|
const files = sourceRoots.flatMap(listFiles).filter(
|
|
(file) =>
|
|
!file
|
|
.slice(repoRoot.length + 1)
|
|
.replaceAll("\\", "/")
|
|
.startsWith("src/lib/query-keys/"),
|
|
);
|
|
|
|
for (const file of files) {
|
|
const text = readFileSync(file, "utf8");
|
|
if (!isClientSource(file, text)) continue;
|
|
|
|
const lines = text.split("\n");
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.replace(/\/\/.*$/, "").trim();
|
|
if (!/(?:queryKey|mutationKey)\s*:\s*\[/.test(trimmed)) continue;
|
|
offenders.push({
|
|
file: file.slice(repoRoot.length + 1),
|
|
line: i + 1,
|
|
snippet: trimmed,
|
|
});
|
|
}
|
|
}
|
|
|
|
expect(
|
|
offenders,
|
|
`Factory bypass — every client TanStack key must come from \`queryKeys.<entry>()\`. ` +
|
|
`Offenders:\n${offenders
|
|
.map((o) => ` ${o.file}:${o.line} ${o.snippet}`)
|
|
.join("\n")}`,
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("invalidateKeys", () => {
|
|
it("calls invalidateQueries for every key in the bundle", async () => {
|
|
const invalidate = vi.fn().mockResolvedValue(undefined);
|
|
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
|
const keys: QueryKey[] = [["a"], ["b", "c"], ["d"]];
|
|
|
|
await invalidateKeys(client, keys);
|
|
|
|
expect(invalidate).toHaveBeenCalledTimes(3);
|
|
expect(invalidate).toHaveBeenNthCalledWith(1, { queryKey: ["a"] });
|
|
expect(invalidate).toHaveBeenNthCalledWith(2, { queryKey: ["b", "c"] });
|
|
expect(invalidate).toHaveBeenNthCalledWith(3, { queryKey: ["d"] });
|
|
});
|
|
|
|
it("continues on partial failure (allSettled semantics)", async () => {
|
|
const invalidate = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockRejectedValueOnce(new Error("boom"))
|
|
.mockResolvedValueOnce(undefined);
|
|
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
|
const keys: QueryKey[] = [["a"], ["b"], ["c"]];
|
|
|
|
const results = await invalidateKeys(client, keys);
|
|
|
|
expect(invalidate).toHaveBeenCalledTimes(3);
|
|
expect(results[0].status).toBe("fulfilled");
|
|
expect(results[1].status).toBe("rejected");
|
|
expect(results[2].status).toBe("fulfilled");
|
|
});
|
|
});
|
|
|
|
// v1.29.x — the shared helper behind the measurement / mood / water fix.
|
|
// `invalidateKeys` (default `refetchType: "active"`) only refetches MOUNTED
|
|
// queries, so the dashboard hero and Today digest — both typically unmounted
|
|
// while the user is on the measurement/mood/nutrients surface — are marked
|
|
// stale but never refetched. Mirrors the v1.29.1 medication-intake fix
|
|
// (`invalidateMedicationReads`); this is the shared version every other
|
|
// write seam now calls alongside its own `*DependentKeys` bundle.
|
|
describe("refetchInactiveDailyReads", () => {
|
|
it("forces an inactive refetch of both the dashboard snapshot and the Today digest", async () => {
|
|
const invalidate = vi.fn().mockResolvedValue(undefined);
|
|
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
|
|
|
await refetchInactiveDailyReads(client);
|
|
|
|
expect(invalidate).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.dashboardSnapshot(),
|
|
refetchType: "inactive",
|
|
});
|
|
expect(invalidate).toHaveBeenCalledWith({
|
|
queryKey: queryKeys.dailyDigest(),
|
|
refetchType: "inactive",
|
|
});
|
|
});
|
|
});
|