Files
HealthLog/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx
T
Marc-André Bombeck 779aac5f78 fix(coach): integration cleanup for the Horizon-B merge
Drop the unused extractCoachClaims helper (+ its CoachClaim types) and the
unused CoachPlanStatus type — the deterministic grader uses the boolean claim
helpers directly, and the plan status enum is consumed via the const array.
Update the dashboard-snapshot suite for the locale-keyed cell: stub the i18n
context and add getQueriesData to the fake query client so the locale-aware
preload probe is exercised.
2026-06-27 11:01:13 +02:00

221 lines
7.8 KiB
TypeScript

/**
* v1.7.0 — open-dashboard auto-refresh contract.
*
* An open dashboard must pick up freshly-synced Withings / HealthKit
* readings without a manual reload. The snapshot hook carries a
* two-minute `refetchInterval` that pauses while the tab is
* backgrounded (`refetchIntervalInBackground: false`). The poll rides
* the warm 60 s server cache and never touches the LLM surfaces.
*
* The hook keeps its warm-cache options (`staleTime: 60_000`,
* `refetchOnMount: false`) so a return-to-dashboard within a minute
* stays a free cache hit, and the queryKey stays the centralised factory
* entry. v1.18.9 (#38) flips `refetchOnWindowFocus` to `true` so a
* background sync — which fires no client mutation event — surfaces on
* return-to-tab; the `staleTime` gate keeps rapid focus toggles free.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
const useQueryMock =
vi.fn<(opts: Record<string, unknown>) => { data: undefined }>();
const getQueryDataMock = vi.fn();
const setQueryDataMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: Record<string, unknown>) => useQueryMock(opts),
useQueryClient: () => ({
getQueryData: getQueryDataMock,
setQueryData: setQueryDataMock,
}),
}));
// v1.21.3 (b) — the hook now keys the cell by the active locale via
// `useTranslations()`; the suite exercises the hook body directly (react-query
// is mocked), so stub the i18n context to a fixed locale rather than mounting a
// provider.
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ locale: "en" }),
}));
import {
useDashboardSnapshot,
prefetchDashboardSnapshot,
_resetDashboardSnapshotPreloadForTests,
} from "../use-dashboard-snapshot";
import { retryOnceOnTransientError } from "../retry-transient";
import { queryKeys } from "@/lib/query-keys";
import type { QueryClient } from "@tanstack/react-query";
/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */
function envelopeResponse(data: unknown): Response {
return new Response(JSON.stringify({ data, error: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
afterEach(() => {
useQueryMock.mockClear();
getQueryDataMock.mockReset();
setQueryDataMock.mockClear();
_resetDashboardSnapshotPreloadForTests();
vi.unstubAllGlobals();
});
describe("useDashboardSnapshot — auto-refresh on an open page", () => {
function lastOpts() {
expect(useQueryMock).toHaveBeenCalledTimes(1);
return useQueryMock.mock.calls[0]![0];
}
it("polls every two minutes and pauses in the background", () => {
useDashboardSnapshot();
const opts = lastOpts();
expect(opts.refetchInterval).toBe(120_000);
expect(opts.refetchIntervalInBackground).toBe(false);
});
it("keeps the warm-cache options so a return within a minute is free", () => {
useDashboardSnapshot();
const opts = lastOpts();
expect(opts.staleTime).toBe(60_000);
expect(opts.refetchOnMount).toBe(false);
});
it("refetches on window focus so a background sync surfaces on return", () => {
useDashboardSnapshot();
const opts = lastOpts();
expect(opts.refetchOnWindowFocus).toBe(true);
});
it("routes the queryKey through the centralised factory, keyed by locale", () => {
useDashboardSnapshot();
const opts = lastOpts();
// v1.21.3 (b) — the live cell carries the active locale; a zero-arg
// invalidation still prefix-matches it.
expect(opts.queryKey).toEqual(queryKeys.dashboardSnapshot("en"));
});
it("retries once on transient failures only (network / 5xx, never 4xx)", () => {
useDashboardSnapshot();
const opts = lastOpts();
expect(opts.retry).toBe(retryOnceOnTransientError);
});
it("seeds the widgets cache from the snapshot layout when the slot is empty", async () => {
const layout = { widgets: [] };
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(envelopeResponse({ layout, tiles: {} })),
);
getQueryDataMock.mockReturnValue(undefined);
useDashboardSnapshot();
const opts = lastOpts();
await (opts.queryFn as () => Promise<unknown>)();
expect(setQueryDataMock).toHaveBeenCalledWith(
queryKeys.dashboardWidgets(),
layout,
);
});
it("never clobbers an already-populated widgets cache", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
envelopeResponse({ layout: { widgets: [] }, tiles: {} }),
),
);
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
useDashboardSnapshot();
const opts = lastOpts();
await (opts.queryFn as () => Promise<unknown>)();
expect(setQueryDataMock).not.toHaveBeenCalled();
});
});
describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
function fakeQueryClient(
state?: { data: unknown; dataUpdatedAt: number } | undefined,
): QueryClient {
return {
// v1.21.3 (b) — the preloader probes ALL locale-keyed snapshot cells
// via `getQueriesData` (prefix) then reads each cell's state. A fresh
// `state` stands in for one warm locale cell; absent → no warm cell.
getQueriesData: vi
.fn()
.mockReturnValue(
state ? [[queryKeys.dashboardSnapshot("en"), state.data]] : [],
),
getQueryState: vi.fn().mockReturnValue(state),
getQueryData: getQueryDataMock,
setQueryData: setQueryDataMock,
} as unknown as QueryClient;
}
function stubFetch(
payload: unknown = { layout: { widgets: [] }, tiles: {} },
) {
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(envelopeResponse(payload)));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
it("never writes the query cache itself — the response parks until the mounted queryFn consumes it", async () => {
const fetchMock = stubFetch();
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
prefetchDashboardSnapshot(fakeQueryClient());
expect(fetchMock).toHaveBeenCalledTimes(1);
// The prefetch must not commit data: committing pre-hydration is the
// React #418 mismatch the handoff exists to prevent.
expect(setQueryDataMock).not.toHaveBeenCalled();
useDashboardSnapshot();
const opts = lastOptsOf(useQueryMock);
await (opts.queryFn as () => Promise<unknown>)();
// The mounted cell consumed the in-flight request — no second fetch.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("is a no-op while a handoff is already parked", () => {
const fetchMock = stubFetch();
prefetchDashboardSnapshot(fakeQueryClient());
prefetchDashboardSnapshot(fakeQueryClient());
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("skips the fetch when the snapshot cache is still fresh", () => {
const fetchMock = stubFetch();
prefetchDashboardSnapshot(
fakeQueryClient({ data: { tiles: {} }, dataUpdatedAt: Date.now() }),
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls back to a fresh fetch when the preload failed — the mounted cell owns error surfacing", async () => {
const layout = { widgets: [] };
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("HTTP 401"))
.mockResolvedValueOnce(envelopeResponse({ layout, tiles: {} }));
vi.stubGlobal("fetch", fetchMock);
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
prefetchDashboardSnapshot(fakeQueryClient());
useDashboardSnapshot();
const opts = lastOptsOf(useQueryMock);
const snap = await (opts.queryFn as () => Promise<{ layout: unknown }>)();
expect(snap.layout).toEqual(layout);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
function lastOptsOf(mock: typeof useQueryMock): Record<string, unknown> {
const call = mock.mock.calls.at(-1);
expect(call).toBeDefined();
return call![0];
}