Skip to content

Commit d2df958

Browse files
author
Johnpii1
committed
Add integration test for holder count display updating after React Query cache invalidation
1 parent 4041f8e commit d2df958

9 files changed

Lines changed: 1031 additions & 32 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"specId": "a3f82c1e-9d47-4b8e-bc63-7e5a2f3d1094", "workflowType": "requirements-first", "specType": "feature"}

.kiro/specs/holder-count-cache-invalidation-test/design.md

Lines changed: 435 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Requirements Document
2+
3+
## Introduction
4+
5+
This feature adds an integration test that verifies the creator detail page updates its displayed holder count after a React Query cache invalidation triggers a refetch. The page currently renders a `MiniStatChip` whose "Audience" value is derived from `FEATURED_CREATOR_KEY_HOLDER_COUNT`. The test must confirm that when the cache entry for a creator is invalidated and the refetch resolves with a new value, the UI reflects the updated count without a full page reload.
6+
7+
The scope is purely test infrastructure: no production behaviour changes are required. The test will wrap the component under test with a `QueryClientProvider`, pre-seed the cache with an initial creator payload, then programmatically invalidate the query key and mock the refetch to return an updated holder count. Assertions confirm the new value is visible and the old value is gone.
8+
9+
## Glossary
10+
11+
- **Creator_Detail_Page**: The section of `LandingPage` (and its composing components) that displays creator statistics including the holder count "Audience" chip.
12+
- **Holder_Count**: The integer representing the number of wallets that hold at least one key for a given creator. Rendered via `getFeaturedCreatorKeyHolderCopy` as a formatted string inside a `MiniStatChip`.
13+
- **React_Query_Cache**: The in-memory data store managed by `@tanstack/react-query` (v5). Identified by a query key; entries can be invalidated with `queryClient.invalidateQueries`.
14+
- **Query_Key**: The array used to identify a cache entry, e.g. `['creator', creatorId]`.
15+
- **QueryClient**: The TanStack Query client instance that owns the cache and coordinates fetches.
16+
- **QueryClientProvider**: The React context provider that makes a `QueryClient` available to components under test.
17+
- **Test_Wrapper**: A helper that wraps a component under test with all required providers (`QueryClientProvider`, `MemoryRouter`) so it renders in isolation.
18+
- **Mock_Fetch**: A `vi.fn()` stub that replaces the real network call, returning controlled data for each invocation.
19+
- **Invalidation**: The act of marking one or more cache entries as stale, causing React Query to trigger a background refetch on the next render of a subscribed component.
20+
- **Refetch**: The background network request that React Query fires after invalidation; in tests this is fulfilled by the `Mock_Fetch`.
21+
22+
## Requirements
23+
24+
### Requirement 1: Initial Holder Count Renders Correctly
25+
26+
**User Story:** As a developer running the integration test suite, I want the creator detail page to render the correct initial holder count from the seeded cache, so that the test has a verified baseline before invalidation.
27+
28+
#### Acceptance Criteria
29+
30+
1. WHEN the `Test_Wrapper` renders the creator detail section with a `QueryClient` pre-seeded with `initialCount` keys in the cache entry, THE `Creator_Detail_Page` SHALL display a formatted string derived from `initialCount` (e.g. `"42 key holders"`) in the holder count element.
31+
2. WHEN the initial render completes without triggering a network call, THE `Mock_Fetch` SHALL have been called zero times.
32+
3. IF the `initialCount` is `0`, THEN THE `Creator_Detail_Page` SHALL display `"No key holders yet"` in the holder count element.
33+
4. IF the `initialCount` is `null`, THEN THE `Creator_Detail_Page` SHALL display `"Key holders unavailable"` in the holder count element.
34+
35+
---
36+
37+
### Requirement 2: Cache Invalidation Triggers a Refetch
38+
39+
**User Story:** As a developer running the integration test suite, I want calling `queryClient.invalidateQueries` on the creator query key to trigger exactly one refetch call to the `Mock_Fetch`, so that I can confirm React Query's invalidation mechanism is wired correctly.
40+
41+
#### Acceptance Criteria
42+
43+
1. WHEN `queryClient.invalidateQueries` is called with the creator's `Query_Key`, THE `QueryClient` SHALL mark the cache entry as stale and schedule a background refetch.
44+
2. WHEN the invalidation-driven refetch executes, THE `Mock_Fetch` SHALL be called exactly once with the creator's identifier as a parameter.
45+
3. WHILE the refetch is in-flight, THE `Creator_Detail_Page` SHALL continue to display the previously cached holder count without showing a blank or error state.
46+
4. IF `queryClient.invalidateQueries` is called with a `Query_Key` that does not match any active query, THEN THE `Mock_Fetch` SHALL NOT be called.
47+
48+
---
49+
50+
### Requirement 3: Updated Holder Count Renders After Refetch
51+
52+
**User Story:** As a developer running the integration test suite, I want the creator detail page to display the updated holder count returned by the refetch, so that I can confirm the UI reflects fresh data after cache invalidation.
53+
54+
#### Acceptance Criteria
55+
56+
1. WHEN the refetch resolves with `updatedCount`, THE `Creator_Detail_Page` SHALL display the formatted string derived from `updatedCount` (e.g. `"99 key holders"`) in the holder count element.
57+
2. WHEN the updated count is visible, THE `Creator_Detail_Page` SHALL NOT display the formatted string that was derived from `initialCount`.
58+
3. THE `Creator_Detail_Page` SHALL display the updated count without requiring a full page reload (i.e. `window.location.reload` SHALL NOT be called during the test).
59+
4. WHEN `updatedCount` differs from `initialCount`, THE display transition SHALL occur within the same mounted component instance, confirming no unmount–remount cycle was required.
60+
61+
---
62+
63+
### Requirement 4: Test Isolation and No Side Effects
64+
65+
**User Story:** As a developer running the integration test suite, I want each test case to use a fresh `QueryClient` instance and reset all mocks, so that tests do not leak state into one another.
66+
67+
#### Acceptance Criteria
68+
69+
1. THE `Test_Wrapper` SHALL instantiate a new `QueryClient` in `beforeEach` (or equivalent per-test setup) so that cache state from one test does not influence another.
70+
2. THE `Mock_Fetch` SHALL be reset (via `vi.resetAllMocks()` or `mockFn.mockReset()`) before each test so that call counts and return values are clean.
71+
3. WHEN a test completes, THE `Test_Wrapper` SHALL unmount cleanly without leaving dangling subscriptions or timers that could affect subsequent tests.
72+
4. THE test file SHALL NOT import or call any production network layer (e.g. `courseService`) directly; all external I/O SHALL be replaced by `Mock_Fetch` stubs.
73+
74+
---
75+
76+
### Requirement 5: Holder Count Display Format Consistency
77+
78+
**User Story:** As a developer running the integration test suite, I want the holder count format assertions to match the format produced by `getFeaturedCreatorKeyHolderCopy`, so that the test accurately reflects what a real user would see.
79+
80+
#### Acceptance Criteria
81+
82+
1. THE `Creator_Detail_Page` SHALL format a positive `holderCount` as `"<compactNumber> key holders"` where `<compactNumber>` is the output of `formatCompactNumber(holderCount)`.
83+
2. WHEN `holderCount` is `0`, THE `Creator_Detail_Page` SHALL display exactly `"No key holders yet"`.
84+
3. WHEN `holderCount` is `null` or `undefined`, THE `Creator_Detail_Page` SHALL display exactly `"Key holders unavailable"`.
85+
4. FOR ALL valid non-negative integer values of `holderCount`, THE display string produced by `getFeaturedCreatorKeyHolderCopy(holderCount)` SHALL be consistent with the string rendered in the DOM (round-trip equivalence property).
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import MiniStatChip from '@/components/common/MiniStatChip';
2+
import { useCreatorHolderCount } from '@/hooks/useCreatorHolderCount';
3+
import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils';
4+
5+
interface FeaturedCreatorAudienceChipProps {
6+
creatorId: string;
7+
fetchHolderCount: (id: string) => Promise<number | null>;
8+
}
9+
10+
export function FeaturedCreatorAudienceChip({
11+
creatorId,
12+
fetchHolderCount,
13+
}: FeaturedCreatorAudienceChipProps) {
14+
const { count } = useCreatorHolderCount(creatorId, fetchHolderCount);
15+
const copy = getFeaturedCreatorKeyHolderCopy(count);
16+
17+
return (
18+
<MiniStatChip
19+
label="Audience"
20+
value={copy.value}
21+
explanation={copy.explanation}
22+
/>
23+
);
24+
}

src/hooks/useCreatorHolderCount.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
3+
export interface HolderCountResult {
4+
count: number | null;
5+
isLoading: boolean;
6+
isError: boolean;
7+
}
8+
9+
/**
10+
* Fetches the holder count for a given creator via React Query.
11+
* Query key: ['creator', creatorId, 'holderCount']
12+
*
13+
* The queryFn is injected as a parameter so tests can supply a mock
14+
* without module-level vi.mock() patching.
15+
*/
16+
export function useCreatorHolderCount(
17+
creatorId: string,
18+
fetchHolderCount: (id: string) => Promise<number | null>
19+
): HolderCountResult {
20+
const { data, isLoading, isError } = useQuery({
21+
queryKey: ['creator', creatorId, 'holderCount'],
22+
queryFn: () => fetchHolderCount(creatorId),
23+
staleTime: 30_000,
24+
});
25+
26+
return {
27+
count: data ?? null,
28+
isLoading,
29+
isError,
30+
};
31+
}

src/pages/LandingPage.tsx

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import CompactSectionSubtitle from '@/components/common/CompactSectionSubtitle';
2323
import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid';
2424
import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow';
2525
import MiniStatChip from '@/components/common/MiniStatChip';
26+
import { FeaturedCreatorAudienceChip } from '@/components/common/FeaturedCreatorAudienceChip';
2627
import MarketplaceSection from '@/components/common/MarketplaceSection';
2728
import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill';
2829
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
@@ -78,28 +79,6 @@ const FEATURED_CREATOR_FACTS = [
7879
const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null;
7980
const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0;
8081

81-
const getFeaturedCreatorKeyHolderCopy = (count: number | null) => {
82-
if (count == null) {
83-
return {
84-
value: 'Key holders unavailable',
85-
explanation: 'Key holder data is not available yet.',
86-
};
87-
}
88-
89-
if (count === 0) {
90-
return {
91-
value: 'No key holders yet',
92-
explanation:
93-
'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.',
94-
};
95-
}
96-
97-
return {
98-
value: `${formatCompactNumber(count)} key holders`,
99-
explanation: 'Number of wallets that currently hold at least one key.',
100-
};
101-
};
102-
10382
// Fallback demo data in case API fails
10483
const DEMO_CREATORS: Course[] = [
10584
{
@@ -557,10 +536,6 @@ function LandingPage() {
557536
const start = safePage * PAGE_SIZE;
558537
return filteredCreators.slice(start, start + PAGE_SIZE);
559538
}, [filteredCreators, safePage]);
560-
const featuredCreatorKeyHolderCopy = getFeaturedCreatorKeyHolderCopy(
561-
FEATURED_CREATOR_KEY_HOLDER_COUNT
562-
);
563-
564539
// Choose the featured creator from live data when available, otherwise
565540
// fall back to the demo featured creator. This keeps the profile panel
566541
// reactive to backend updates (supply, price, etc.).
@@ -1195,12 +1170,9 @@ function LandingPage() {
11951170
value="Verified creator"
11961171
explanation="Creator has completed identity verification with Access Layer."
11971172
/>
1198-
<MiniStatChip
1199-
label="Audience"
1200-
value={featuredCreatorKeyHolderCopy.value}
1201-
explanation={
1202-
featuredCreatorKeyHolderCopy.explanation
1203-
}
1173+
<FeaturedCreatorAudienceChip
1174+
creatorId="featured-creator"
1175+
fetchHolderCount={() => Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)}
12041176
/>
12051177
<MiniStatChip
12061178
label="Access"

0 commit comments

Comments
 (0)