From e26a8f3824721eac8f0e85744e9c196e26acdbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 18:38:23 +0200 Subject: [PATCH] Render the withheld controls in the affordance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../delegated-write-affordances.test.tsx | 192 +++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index ae1f4e03d..10f672f5a 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -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( - {node}, + + {node} + , ); } @@ -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 = ; + + 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 = ; + 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 = ; + + 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;