Render the withheld controls in the affordance suite

Three of the newly gated surfaces are query-backed, so the suite gains a
query client and a cache seed rather than a hook mock: the component
under test stays the real one. Each leg asserts the rail, the card and
the reminder row are still THERE — only the mutating control is gone.

Breaking each gate in turn puts every leg red.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 18:38:23 +02:00
1 parent 45363edb77
commit e26a8f3824
1 file changed
+189 -3
@@ -18,9 +18,16 @@
* read-only legs for the intake row and the card menu go red.
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
* somebody else's record" goes red.
* - `TodayHero` passing `onDismiss` / `onAction` unconditionally → the rail
* legs go red, printing the dismiss control and the check-in buttons.
* - `VorsorgeDashboardCard` dropping its `canManage` bail → the mark-done
* leg goes red.
* - `EpisodeDocumentsCard` dropping its `canManage` bail → the link +
* upload leg goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
@@ -59,7 +66,9 @@ vi.mock("@/hooks/use-auth", () => ({
email: null,
role: "USER",
avatarUrl: null,
modules: {},
// The episode-documents card renders only for an account with the
// vault switched on; every other surface here ignores the map.
modules: { inboundDocuments: true },
accountAccess: mockAccessRef.value,
},
isAuthenticated: true,
@@ -68,16 +77,56 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
// The Vorsorge summary pushes to the check-in page for a screening reminder.
// Nothing below clicks, so a stub router is enough to let it mount.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
// Never resolves inside the synchronous render, so every query below stays
// pending and each component paints its own loading branch. The controls this
// file is about live outside that branch.
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
apiPut: () => new Promise(() => {}),
apiPatch: () => new Promise(() => {}),
apiDelete: () => new Promise(() => {}),
}));
import { DeleteButton } from "@/components/data-list/delete-button";
import { SelectionActionBar } from "@/components/data-list/selection-action-bar";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { visibleCaptureKinds } from "@/components/layout/capture-picker";
import { TodayHero } from "@/components/daily/today-hero";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { queryKeys } from "@/lib/query-keys";
import type { DailyDigest } from "@/lib/daily/digest";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
function render(access: AccountAccess, node: React.ReactNode): string {
function render(
access: AccountAccess,
node: React.ReactNode,
/**
* Seed the cache a query-backed component reads, so it paints its populated
* branch inside a synchronous render instead of its skeleton. Cheaper and
* truer than mocking the hook: the component under test is the real one.
*/
seed?: (client: QueryClient) => void,
): string {
mockAccessRef.value = access;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
@@ -167,6 +216,143 @@ describe("the medication card menu", () => {
});
});
/* -------------------------------------------------------------------------- */
/* The Today rail */
/* -------------------------------------------------------------------------- */
const DIGEST: DailyDigest = {
generatedAt: "2026-08-03T06:00:00.000Z",
phase: "final",
sleepPending: false,
score: null,
topSignal: null,
briefingLead: "A steady week so far.",
line: "A steady week so far.",
justIn: null,
reactionLine: null,
worthALook: [
{
kind: "milestone",
itemKey: "milestone:steps-10k",
title: "Ten thousand steps",
actions: [],
},
{
kind: "coach_checkin",
title: "Still worth keeping?",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: "coach.plan.keep:plan-1",
},
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
},
],
};
describe("the Today rail's mutating affordances", () => {
const node = <TodayHero digest={DIGEST} />;
it("offers the dismiss and the check-in answer in the caller's own record", () => {
const html = render(OWN_RECORD, node);
expect(html).toContain('data-slot="priority-card-dismiss"');
expect(html).toContain("Keep it");
});
it("withholds both inside somebody else's record, at either level", () => {
// Dismissing an observation and answering a coach check-in are neither of
// them an admitted create, and both write through routes that resolve the
// CALLER — so a delegate tapping either would file it against their own
// record if the route allowed it, and gets a 403 because it does not.
for (const access of [READ_ONLY, WRITABLE]) {
const html = render(access, node);
expect(html).not.toContain('data-slot="priority-card-dismiss"');
expect(html).not.toContain("Keep it");
// The rail itself stays: reading what is worth a look is the point of
// opening somebody's record, and the navigation action survives with it.
expect(html).toContain('data-slot="today-hero-rail"');
expect(html).toContain('href="/checkups"');
}
});
});
/* -------------------------------------------------------------------------- */
/* Vorsorge */
/* -------------------------------------------------------------------------- */
const REMINDER: MeasurementReminder = {
id: "rem-1",
label: "Dental check-up",
measurementType: null,
intervalDays: 180,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: "2026-08-10T09:00:00.000Z",
lastSatisfiedAt: null,
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
describe("the Vorsorge summary's mark-done", () => {
const node = <VorsorgeDashboardCard />;
const seed = (client: QueryClient) =>
client.setQueryData(queryKeys.measurementReminders(), [REMINDER]);
it("is offered in the caller's own record", () => {
const html = render(OWN_RECORD, node, seed);
expect(html).toContain("Dental check-up");
expect(html).toContain("Done");
});
it("is absent inside somebody else's record, at either level", () => {
// `/checkups` already withheld this exact action; the dashboard summary
// offering it was the inconsistency that showed nobody had asked.
for (const access of [READ_ONLY, WRITABLE]) {
const html = render(access, node, seed);
expect(html).toContain("Dental check-up");
expect(html).not.toContain("Done");
}
});
});
/* -------------------------------------------------------------------------- */
/* Documents on an illness episode */
/* -------------------------------------------------------------------------- */
describe("linking a document to an illness episode", () => {
const node = <EpisodeDocumentsCard episodeId="ep-1" />;
it("offers the link and the upload in the caller's own record", () => {
const html = render(OWN_RECORD, node);
expect(html).toContain(">Link<");
expect(html).toContain("Upload");
});
it("offers neither inside somebody else's record, at either level", () => {
// Both write through `POST /api/documents/inbound/bulk`, which the vault
// gates on the same answer throughout — the episode side of the same link
// had no gate at all, and the upload deep-linked to a page whose own
// upload control is already withheld.
for (const access of [READ_ONLY, WRITABLE]) {
const html = render(access, node);
expect(html).not.toContain(">Link<");
expect(html).not.toContain("Upload");
// The card itself stays — reading the episode's documents is a read.
expect(html).toContain('data-slot="episode-documents-card"');
}
});
});
describe("the capture picker's kinds", () => {
const ALL = ["measurement", "medication", "mood", "water"] as const;