test(vaccinations): reconcile the structural guards with the new surfaces

- freeze the booster-mint and upload-suggestion routes into the sharing
  surface guard with their why sentences; delegable-write and mutation
  inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
  like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
  success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
  group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
  longer throws for a fully-selected report
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 21:43:28 +02:00
1 parent 4db70d3eb1
commit 958f3bc50a
20 files changed
+147 -19

No files matched your search

+3 -3
View File
@@ -932,7 +932,7 @@ paths:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
@@ -1037,7 +1037,7 @@ paths:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
@@ -16157,7 +16157,7 @@ components:
type: number
const: 2
leaves:
maxItems: 92
maxItems: 93
type: array
items:
type: string
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} hat ein Dokument geändert",
"documentDiscard": "{name} hat ein Dokument verworfen",
"medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig",
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen"
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen",
"vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant"
},
"section": {
"measurements": {
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} changed a document",
"documentDiscard": "{name} discarded a document",
"medicationIntakeImportFinished": "The dose import {name} started has finished",
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded"
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded",
"vaccinationBoosterPlanned": "{name} planned a booster"
},
"section": {
"measurements": {
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} cambió un documento",
"documentDiscard": "{name} descartó un documento",
"medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado",
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}"
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}",
"vaccinationBoosterPlanned": "{name} planificó un refuerzo"
},
"section": {
"measurements": {
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} a modifié un document",
"documentDiscard": "{name} a écarté un document",
"medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé",
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}"
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}",
"vaccinationBoosterPlanned": "{name} a planifié un rappel"
},
"section": {
"measurements": {
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} ha modificato un documento",
"documentDiscard": "{name} ha scartato un documento",
"medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata",
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}"
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}",
"vaccinationBoosterPlanned": "{name} ha pianificato un richiamo"
},
"section": {
"measurements": {
+2 -1
View File
@@ -10145,7 +10145,8 @@
"documentUpdate": "{name} zmienił dokument",
"documentDiscard": "{name} odrzucił dokument",
"medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się",
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}"
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}",
"vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą"
},
"section": {
"measurements": {
@@ -400,6 +400,16 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/vaccinations/use-vaccinations.ts",
destroys:
"one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound",
triggers: ["components/vaccinations/vaccination-sheet.tsx"],
// The route soft-deletes and a restore route exists, but no surface offers
// the undo yet, so from a person's side the dose is gone.
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
// ── Cycle ───────────────────────────────────────────────────────────────
{
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(92);
expect(ALL_LEAF_IDS.length).toBe(93);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(16);
});
it("places every leaf in exactly one group", () => {
+12 -2
View File
@@ -951,6 +951,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.",
},
"app/api/vaccinations/[id]/booster/route.ts": {
domain: "profile",
why: "Arming the booster reminder a logged dose suggests. The antigen is read from the dose's own catalogue entry server-side, never from the body, so a delegate cannot key a reminder onto an antigen the dose does not contain, and the reminder is minted under the RECORD — it is the owner's booster plan and the owner's phone it rings, which is correct even when a helper transcribes the Impfpass.",
},
"app/api/vaccinations/suggest/route.ts": {
domain: "profile",
why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1401,6 +1409,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.",
"app/api/vaccinations/route.ts":
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
"app/api/vaccinations/[id]/booster/route.ts":
"Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1902,7 +1912,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* record list, one create on the write literal, and the edit/delete pair plus
* the restore on the manage literal. 215 -> 221.
*/
const FROZEN_ENTRY_COUNT = 221;
const FROZEN_ENTRY_COUNT = 224;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2663,7 +2673,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -303,6 +303,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/vaccinations/[id]/route.ts",
"src/app/api/vaccinations/[id]/restore/route.ts",
"src/app/api/vaccinations/[id]/links/route.ts",
// The booster mint and the upload suggestion are the same posture: a restore
// or an import that arms a booster, or a document review that files a scan,
// must keep working with the surface hidden, so the data routes stay exempt
// while the nav entry, the picker and the report leaf hide.
"src/app/api/vaccinations/[id]/booster/route.ts",
"src/app/api/vaccinations/suggest/route.ts",
];
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: { findMany: vi.fn() },
encounter: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
vaccinationRecord: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
@@ -168,6 +169,7 @@ beforeEach(() => {
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
@@ -193,7 +193,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/3");
expect(groupChip(html, "history")).toBe("0/4");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -19,6 +19,7 @@
import { useState } from "react";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
AlertDialog,
AlertDialogAction,
@@ -84,7 +85,7 @@ export function BoosterMintPrompt({
id: record.id,
body: { intervalMonths: months, label: label.trim() || defaultLabel },
});
toast.success(t("vaccinations.booster.planned"));
toastWrittenOutcome("success", t("vaccinations.booster.planned"));
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("vaccinations.booster.failed"),
@@ -21,6 +21,7 @@ import { Check, Syringe } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useLinkDocumentToVaccination,
@@ -68,7 +69,7 @@ export function VaccinationDocumentSuggestion({
{
onSuccess: () => {
setLinkedId(candidate.id);
toast.success(t("vaccinations.suggestion.linked"));
toastWrittenOutcome("success", t("vaccinations.suggestion.linked"));
},
onError: () => toast.error(t("vaccinations.suggestion.failed")),
},
@@ -28,6 +28,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { useVaccinationMutations, type Vaccination } from "./use-vaccinations";
import {
@@ -73,11 +74,11 @@ export function VaccinationSheet({
try {
if (isEdit) {
await update.mutateAsync({ id: vaccination.id, body });
toast.success(t("vaccinations.updated"));
toastWrittenOutcome("success", t("vaccinations.updated"));
onOpenChange(false);
} else {
const created = await create.mutateAsync(body);
toast.success(t("vaccinations.created"));
toastWrittenOutcome("success", t("vaccinations.created"));
onOpenChange(false);
onCreated?.(created);
}
+4
View File
@@ -195,6 +195,10 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.vaccinationRecordDelete", { name });
case "vaccination.record.restore":
return t("recordSharing.activityVerb.vaccinationRecordRestore", { name });
case "vaccination.booster.planned":
return t("recordSharing.activityVerb.vaccinationBoosterPlanned", {
name,
});
case "practitioner.contact.update":
return t("recordSharing.activityVerb.practitionerContactUpdate", {
name,
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
import {
suggestVaccinationForDate,
vaccinationSuggestWindow,
type VaccinationSuggestionResult,
} from "../document-suggestion";
/**
* The upload suggestion must NEVER pre-select one of two candidates. That
* middle branch is the whole reason the rule returns a shape rather than a best
* guess, and it is the property WR-8 breaks. Dates are relative — a fixed date
* about "within seven days" rots the moment it slides out of the window.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
function daysAgo(days: number): Date {
return new Date(Date.now() - days * DAY_MS);
}
/** A tx whose only used member is `vaccinationRecord.findMany`, returning `rows`. */
function fakeTx(rows: Array<{ id: string; occurredAt: Date }>) {
return {
vaccinationRecord: {
findMany: async () =>
rows.map((r) => ({
id: r.id,
occurredAt: r.occurredAt,
antigenSlug: "tetanus",
vaccineName: null,
})),
},
} as never;
}
async function suggest(
rows: Array<{ id: string; occurredAt: Date }>,
): Promise<VaccinationSuggestionResult> {
return suggestVaccinationForDate(fakeTx(rows), {
userId: "u1",
anchor: daysAgo(0),
});
}
describe("the vaccination upload suggestion", () => {
it("shares the visit moment's window rather than forking it", () => {
const anchor = new Date("2024-06-15T12:00:00.000Z");
const { from, to } = vaccinationSuggestWindow(anchor);
expect((anchor.getTime() - from.getTime()) / DAY_MS).toBe(
ENCOUNTER_SUGGEST_WINDOW_DAYS,
);
expect((to.getTime() - anchor.getTime()) / DAY_MS).toBe(
ENCOUNTER_SUGGEST_WINDOW_DAYS,
);
});
it("pre-selects a single candidate", async () => {
const result = await suggest([{ id: "v1", occurredAt: daysAgo(1) }]);
expect(result.kind).toBe("one");
});
it("offers a picker with NOTHING pre-selected for two candidates (WR-8)", async () => {
const result = await suggest([
{ id: "v1", occurredAt: daysAgo(1) },
{ id: "v2", occurredAt: daysAgo(2) },
]);
// The rule returns "many", carrying no pre-selection. A verdict of "one"
// here is the pre-select-the-first-of-two failure this assertion catches.
expect(result.kind).toBe("many");
if (result.kind === "many") {
expect(result.vaccinations).toHaveLength(2);
}
});
it("offers nothing when there is no candidate", async () => {
const result = await suggest([]);
expect(result.kind).toBe("none");
});
});
+8
View File
@@ -433,6 +433,14 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"restore",
),
handler(
"app/api/vaccinations/[id]/booster/route.ts",
"/api/vaccinations/[id]/booster",
"POST",
"profile",
"write",
"upsert",
),
handler(
"app/api/illness/episodes/[id]/day-logs/route.ts",
"/api/illness/episodes/[id]/day-logs",