mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
4db70d3eb1
commit
958f3bc50a
20 files changed
+147
-19
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user