mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user