|
| 1 | +# Implementation Plan: Holder Count Cache Invalidation Test |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Extract the holder count utility and introduce a thin React Query–backed component layer (`useCreatorHolderCount` + `FeaturedCreatorAudienceChip`) so that cache invalidation is directly observable in tests. Write a property-based integration test covering all four correctness properties and the key edge cases, then verify the full suite passes. |
| 6 | + |
| 7 | +The production diff is intentionally small: one utility file, one hook, one component, and a one-line swap in `LandingPage.tsx`. Everything else lives in the test file. |
| 8 | + |
| 9 | +## Tasks |
| 10 | + |
| 11 | +- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module |
| 12 | + - Create `src/utils/holderCount.utils.ts` |
| 13 | + - Move the `getFeaturedCreatorKeyHolderCopy` function (currently defined inline in `LandingPage.tsx` at line ~81) into the new file |
| 14 | + - Export `HolderCountCopy` interface and `getFeaturedCreatorKeyHolderCopy` function |
| 15 | + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` |
| 16 | + - Keep the existing inline definition in `LandingPage.tsx` for now — it will be replaced in Task 4 |
| 17 | + - _Requirements: 5.1, 5.2, 5.3, 5.4_ |
| 18 | + |
| 19 | +- [x] 2. Create `useCreatorHolderCount` hook |
| 20 | + - Create `src/hooks/useCreatorHolderCount.ts` |
| 21 | + - Implement `useQuery` with query key `['creator', creatorId, 'holderCount']` and `staleTime: 30_000` |
| 22 | + - Accept `fetchHolderCount: (id: string) => Promise<number | null>` as an injected parameter (avoids module-level `vi.mock` in tests) |
| 23 | + - Export `HolderCountResult` interface `{ count: number | null; isLoading: boolean; isError: boolean }` |
| 24 | + - Return `{ count: data ?? null, isLoading, isError }` |
| 25 | + - _Requirements: 2.1, 2.2, 2.3_ |
| 26 | + |
| 27 | +- [x] 3. Create `FeaturedCreatorAudienceChip` component |
| 28 | + - Create `src/components/common/FeaturedCreatorAudienceChip.tsx` |
| 29 | + - Accept props: `creatorId: string` and `fetchHolderCount: (id: string) => Promise<number | null>` |
| 30 | + - Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy` |
| 31 | + - Render `<MiniStatChip label="Audience" value={copy.value} explanation={copy.explanation} />` |
| 32 | + - Import `MiniStatChip` from `@/components/common/MiniStatChip` |
| 33 | + - Import `useCreatorHolderCount` from `@/hooks/useCreatorHolderCount` |
| 34 | + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` |
| 35 | + - _Requirements: 1.1, 1.3, 1.4, 3.1, 3.2, 5.1, 5.2, 5.3_ |
| 36 | + |
| 37 | +- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip` |
| 38 | + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` |
| 39 | + - Replace the inline `<MiniStatChip label="Audience" …>` block (lines ~1199–1205) with `<FeaturedCreatorAudienceChip creatorId={featuredCreator.id} fetchHolderCount={...} />` |
| 40 | + - Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands) |
| 41 | + - Remove the now-unused `featuredCreatorKeyHolderCopy` derived variable (line ~560–563) and the inline `getFeaturedCreatorKeyHolderCopy` function definition (lines ~81–100) |
| 42 | + - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes |
| 43 | + - _Requirements: 1.1, 3.4_ |
| 44 | + |
| 45 | +- [-] 5. Write the integration test |
| 46 | + - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` |
| 47 | + - [-] 5.1 Set up test scaffolding |
| 48 | + - Import `QueryClient`, `QueryClientProvider` from `@tanstack/react-query`; `MemoryRouter` from `react-router`; `render`, `screen`, `waitFor`, `act` from `@testing-library/react`; `fc` from `fast-check`; `beforeEach`, `afterEach`, `describe`, `expect`, `it`, `vi` from `vitest` |
| 49 | + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` |
| 50 | + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` |
| 51 | + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` |
| 52 | + - Add `vi.mock` stubs for `@/hooks/useNetworkMismatch`, `framer-motion`, and any other heavy transitive dependencies pulled in by `FeaturedCreatorAudienceChip` — mirror the pattern from `LandingPage.keyboard.test.tsx` |
| 53 | + - Define `CREATOR_ID = 'test-creator-42'`; declare `queryClient` and `mockFetchHolderCount` at describe scope |
| 54 | + - `beforeEach`: create fresh `QueryClient({ defaultOptions: { queries: { retry: false } } })` and reset `mockFetchHolderCount` via `vi.fn()` |
| 55 | + - `afterEach`: call `queryClient.clear()` |
| 56 | + - Implement `createWrapper(queryClient)` returning a component that wraps children in `<QueryClientProvider>` + `<MemoryRouter>` |
| 57 | + - _Requirements: 4.1, 4.2, 4.3, 4.4_ |
| 58 | + |
| 59 | + - [~] 5.2 Write property test for Property 1 — initial render round-trip |
| 60 | + - **Property 1: Initial render round-trip** |
| 61 | + - **Validates: Requirements 1.1, 5.4** |
| 62 | + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` |
| 63 | + - For each `count`: create fresh `queryClient`, seed with `queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count)`, render `FeaturedCreatorAudienceChip` with wrapper, assert `screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)` is in the document, assert `mockFetchHolderCount` was NOT called, then `unmount()` |
| 64 | + - _Requirements: 1.1, 1.2, 5.4_ |
| 65 | + |
| 66 | + - [~] 5.3 Write property test for Property 2 — stale-while-revalidate display stability |
| 67 | + - **Property 2: Stale-while-revalidate display stability** |
| 68 | + - **Validates: Requirements 2.3** |
| 69 | + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` |
| 70 | + - For each `initialCount`: seed cache, render component, call `queryClient.invalidateQueries` but do NOT resolve the pending `mockFetchHolderCount` (use a `Promise` that never resolves during the assertion window), assert old value is still visible and no blank/error state |
| 71 | + - _Requirements: 2.3_ |
| 72 | + |
| 73 | + - [~] 5.4 Write property test for Property 3 — post-invalidation update round-trip |
| 74 | + - **Property 3: Post-invalidation update round-trip** |
| 75 | + - **Validates: Requirements 3.1, 3.2, 3.4** |
| 76 | + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 999 }), fc.integer({ min: 1000, max: 1_000_000 }), ...)` with `numRuns: 100` (disjoint ranges guarantee `initialCount !== updatedCount`) |
| 77 | + - For each pair `(initialCount, updatedCount)`: seed cache with `initialCount`, render, spy on `window.location.reload`, invalidate query, await `waitFor` assertion that updated text is visible and old text is gone, assert `reloadSpy` was NOT called, `unmount()` |
| 78 | + - _Requirements: 3.1, 3.2, 3.3, 3.4_ |
| 79 | + |
| 80 | + - [~] 5.5 Write property test for Property 4 — format function round-trip |
| 81 | + - **Property 4: Format function round-trip** |
| 82 | + - **Validates: Requirements 5.1, 5.4** |
| 83 | + - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200` |
| 84 | + - For each `n > 0`: assert `getFeaturedCreatorKeyHolderCopy(n).value === formatCompactNumber(n) + ' key holders'` |
| 85 | + - _Requirements: 5.1, 5.4_ |
| 86 | + |
| 87 | + - [ ]* 5.6 Write edge-case tests |
| 88 | + - `count = 0` renders `"No key holders yet"` — seed cache with `0`, render, assert text present |
| 89 | + - `count = null` renders `"Key holders unavailable"` — seed cache with `null`, render, assert text present |
| 90 | + - Non-matching query key: invalidate a different key, assert `mockFetchHolderCount` was NOT called and display is unchanged |
| 91 | + - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID` |
| 92 | + - _Requirements: 1.3, 1.4, 2.2, 2.4_ |
| 93 | + |
| 94 | +- [~] 6. Checkpoint — run tests and confirm everything passes |
| 95 | + - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/` |
| 96 | + - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests |
| 97 | + - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes) |
| 98 | + - Fix any TypeScript or test errors surfaced; ask the user if questions arise. |
| 99 | + |
| 100 | +## Notes |
| 101 | + |
| 102 | +- Tasks marked with `*` are optional and can be skipped for a faster MVP |
| 103 | +- Each task references specific requirements for traceability |
| 104 | +- The `fetchHolderCount` injection pattern in the hook and component avoids `vi.mock` hoisting complexity — tests pass `vi.fn()` directly as a prop |
| 105 | +- Property tests use disjoint integer ranges in Property 3 to guarantee `initialCount !== updatedCount` without needing a `fc.filter` |
| 106 | +- `retry: false` on the test-scoped `QueryClient` keeps assertions deterministic |
| 107 | +- `fast-check` v4 (`"^4.6.0"`) is already installed as a dev dependency — no new packages needed |
0 commit comments