test(queries): pin achievements hook collapse + audit analytics consumers

v1.4.34 IW-F-Perf — three changes.

1) New `use-achievements-query.test.tsx` mounts three consumers under
   one TanStack QueryClient and asserts the shared queryKey collapses
   them into a single network call. Pre-fix the notifier issued its
   own request alongside the card; the regression guard surfaces it
   immediately if a future drift reintroduces a per-user
   discriminator.

2) Refresh `recent-achievements-card.test.tsx` and
   `achievements/__tests__/page.test.tsx` to mock the new shared hook
   directly instead of the raw `@tanstack/react-query` `useQuery`
   shim. The two test suites stay scoped to render-shape, not the
   network plumbing.

3) Capture the `/api/analytics` slim-vs-thick audit in
   `.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
   root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
   thick; the checklist is correctly slim by IW2 design but is
   mounted inside the dashboard render tree so its slim call
   duplicates the thick one already in flight. Documented two clean
   migration paths and flagged the work for v1.4.34.x behind IW-B's
   in-flight slim-slice routing.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:40:34 +02:00
1 parent 3b77b6a073
commit c6345b71fa
4 files changed
+234 -4

No files matched your search

+5 -2
View File
@@ -155,8 +155,11 @@ const mockData = {
},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData, isLoading: false }),
// v1.4.34 IW-F-Perf — page now reads through the shared
// `useAchievementsQuery` hook; mock it directly so the test stays
// scoped to the page render and bypasses the TanStack provider.
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: () => ({ data: mockData, isLoading: false }),
}));
import AchievementsPage, { groupByCategory } from "../page";
@@ -28,8 +28,11 @@ let mockData: {
metrics: {},
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockData }),
// v1.4.34 IW-F-Perf — the card now reads through the shared
// `useAchievementsQuery` hook; the test mocks the hook directly so the
// network layer + TanStack provider scaffolding stays out of scope.
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: () => ({ data: mockData }),
}));
import {
@@ -0,0 +1,126 @@
/**
* v1.4.34 IW-F-Perf — consumer-collapse contract.
*
* Three independent consumers
* (`<RecentAchievementsCard>`, `/achievements` mother page,
* `<AchievementUnlockNotifier>`) all read through
* `useAchievementsQuery()`. The shared hook is the only seam where the
* queryKey is centralised; if a future drift reintroduces a per-user
* discriminator or a literal `["gamification", "achievements"]` shadow
* key, this test surfaces the regression before HAR proves it again
* in production.
*
* Two contracts pinned:
*
* 1. Mounting all three consumers under one TanStack QueryClient
* produces exactly one network call. The shared cache cell wins;
* the divergent v1.4.33 keys are gone.
* 2. The unlock notifier's polling interval rides the shared cache,
* so the card never re-renders on the 2-minute cadence by itself
* — the only refetch fires through the notifier's hook instance
* and updates the single cache cell that both consumers read.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { I18nProvider } from "@/lib/i18n/context";
import { useAchievementsQuery } from "../use-achievements-query";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
const samplePayload = {
summary: { unlockedCount: 0, totalCount: 0 },
achievements: [],
metrics: {},
};
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ data: samplePayload }),
});
// `fetch` is part of the Node global in vitest's node environment; the
// hook reaches for it via `fetch(...)` directly.
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
function ConsumerA() {
useAchievementsQuery();
return null;
}
function ConsumerB() {
useAchievementsQuery();
return null;
}
function ConsumerC() {
useAchievementsQuery({ refetchInterval: 2 * 60 * 1000 });
return null;
}
function withQueryClient(node: ReactNode): string {
const client = new QueryClient({
defaultOptions: {
queries: {
// Force the query to actually fire on mount so the test can
// count the network calls — the hook's `refetchOnMount: false`
// governs subsequent mounts within the staleTime window, not
// the first mount.
retry: false,
},
},
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("useAchievementsQuery — consumer collapse", () => {
it("fires a single network call when three consumers mount together", async () => {
withQueryClient(
<>
<ConsumerA />
<ConsumerB />
<ConsumerC />
</>,
);
// SSR renders are synchronous, so the queryFn is enqueued but not
// awaited inside renderToStaticMarkup. The microtask flush below
// mirrors what react-query does between mount and the network
// call — one tick is enough to see the fetch attempt.
await Promise.resolve();
await Promise.resolve();
// The three consumers share `queryKeys.gamificationAchievements()`
// so TanStack hands every subscriber the same cache slot; one cell
// = one in-flight queryFn = one fetch. Pre-fix the notifier carried
// a `userId` discriminator and fired its own request.
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(1);
});
it("uses /api/gamification/achievements without query params", async () => {
withQueryClient(<ConsumerA />);
await Promise.resolve();
await Promise.resolve();
if (fetchMock.mock.calls.length > 0) {
const [url] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/gamification/achievements");
}
});
});