From d2df958be977dd41aef16f3d39693e2eb39dd80d Mon Sep 17 00:00:00 2001 From: Johnpii1 Date: Sat, 27 Jun 2026 23:06:07 +0100 Subject: [PATCH] Add integration test for holder count display updating after React Query cache invalidation --- .../.config.kiro | 1 + .../design.md | 435 ++++++++++++++++++ .../requirements.md | 85 ++++ .../tasks.md | 107 +++++ .../common/FeaturedCreatorAudienceChip.tsx | 24 + src/hooks/useCreatorHolderCount.ts | 31 ++ src/pages/LandingPage.tsx | 36 +- .../holderCountCacheInvalidation.test.tsx | 316 +++++++++++++ src/utils/holderCount.utils.ts | 28 ++ 9 files changed, 1031 insertions(+), 32 deletions(-) create mode 100644 .kiro/specs/holder-count-cache-invalidation-test/.config.kiro create mode 100644 .kiro/specs/holder-count-cache-invalidation-test/design.md create mode 100644 .kiro/specs/holder-count-cache-invalidation-test/requirements.md create mode 100644 .kiro/specs/holder-count-cache-invalidation-test/tasks.md create mode 100644 src/components/common/FeaturedCreatorAudienceChip.tsx create mode 100644 src/hooks/useCreatorHolderCount.ts create mode 100644 src/pages/__tests__/holderCountCacheInvalidation.test.tsx create mode 100644 src/utils/holderCount.utils.ts diff --git a/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro new file mode 100644 index 0000000..908762a --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro @@ -0,0 +1 @@ +{"specId": "a3f82c1e-9d47-4b8e-bc63-7e5a2f3d1094", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/holder-count-cache-invalidation-test/design.md b/.kiro/specs/holder-count-cache-invalidation-test/design.md new file mode 100644 index 0000000..1b3ba83 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/design.md @@ -0,0 +1,435 @@ +# Design Document + +## Feature: Holder Count Cache Invalidation Test + +## Overview + +This design covers the test infrastructure and minimal production code change needed to validate that the creator detail page's "Audience" holder count chip updates correctly after a React Query cache invalidation triggers a refetch — all within the same mounted component instance, without a page reload. + +The production change is small and surgical: extract the holder count value into a `useCreatorHolderCount` custom hook backed by `useQuery`. This makes the component's data dependency explicit and directly testable via React Query's cache API. The integration test then wraps the component with a fresh `QueryClientProvider`, pre-seeds the cache, invalidates the query key, and asserts the updated count appears. + +**Key constraints:** +- The test must confirm the update happens within the same mounted component instance (no remount). +- Each test uses a fresh `QueryClient` instance to prevent inter-test cache contamination. +- No production network layer (`courseService`) is imported in the test file; all I/O is replaced by `vi.fn()` stubs. + +--- + +## Architecture + +The feature involves three layers: + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Test Layer (src/pages/__tests__/holderCountCacheInvalidation.test.tsx) │ +│ - Fresh QueryClient per test (beforeEach) │ +│ - vi.fn() mockFetch stub │ +│ - queryClient.setQueryData() to pre-seed cache │ +│ - queryClient.invalidateQueries() to trigger refetch │ +│ - @testing-library/react assertions on MiniStatChip value │ +└─────────────────────┬────────────────────────────────────────────────────┘ + │ renders +┌─────────────────────▼────────────────────────────────────────────────────┐ +│ Component Under Test: FeaturedCreatorAudienceChip │ +│ (src/components/common/FeaturedCreatorAudienceChip.tsx) │ +│ - Calls useCreatorHolderCount(creatorId) │ +│ - Renders │ +└─────────────────────┬────────────────────────────────────────────────────┘ + │ uses +┌─────────────────────▼────────────────────────────────────────────────────┐ +│ Hook: useCreatorHolderCount │ +│ (src/hooks/useCreatorHolderCount.ts) │ +│ - useQuery({ queryKey: ['creator', creatorId, 'holderCount'], ... }) │ +│ - Returns { count: number | null, isLoading, isError } │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +The existing `LandingPage.tsx` continues to work unchanged for users — it renders the same `MiniStatChip` via the new `FeaturedCreatorAudienceChip` component, which replaces the inline constant-backed chip. This keeps the diff minimal and avoids touching unrelated LandingPage logic. + +--- + +## Components and Interfaces + +### 1. `useCreatorHolderCount` hook + +**File:** `src/hooks/useCreatorHolderCount.ts` + +```typescript +import { useQuery } from '@tanstack/react-query'; + +export interface HolderCountResult { + count: number | null; + isLoading: boolean; + isError: boolean; +} + +/** + * Fetches the holder count for a given creator via React Query. + * Query key: ['creator', creatorId, 'holderCount'] + * + * The queryFn is injected as a parameter so tests can supply a mock + * without module-level vi.mock() patching. + */ +export function useCreatorHolderCount( + creatorId: string, + fetchHolderCount: (id: string) => Promise +): HolderCountResult { + const { data, isLoading, isError } = useQuery({ + queryKey: ['creator', creatorId, 'holderCount'], + queryFn: () => fetchHolderCount(creatorId), + staleTime: 30_000, + }); + + return { + count: data ?? null, + isLoading, + isError, + }; +} +``` + +**Design decision — injected `fetchHolderCount`:** Rather than importing a service at module level, the fetch function is a parameter. This means tests pass `vi.fn()` directly as a prop, making the hook trivially mockable without `vi.mock()` hoisting. Production callers pass in the real service method. + +### 2. `FeaturedCreatorAudienceChip` component + +**File:** `src/components/common/FeaturedCreatorAudienceChip.tsx` + +```typescript +import MiniStatChip from '@/components/common/MiniStatChip'; +import { useCreatorHolderCount } from '@/hooks/useCreatorHolderCount'; +import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils'; + +interface FeaturedCreatorAudienceChipProps { + creatorId: string; + fetchHolderCount: (id: string) => Promise; +} + +export function FeaturedCreatorAudienceChip({ + creatorId, + fetchHolderCount, +}: FeaturedCreatorAudienceChipProps) { + const { count } = useCreatorHolderCount(creatorId, fetchHolderCount); + const copy = getFeaturedCreatorKeyHolderCopy(count); + + return ( + + ); +} +``` + +### 3. `getFeaturedCreatorKeyHolderCopy` utility extraction + +The existing inline function in `LandingPage.tsx` is moved to a shared utility so both the component and the test can import it: + +**File:** `src/utils/holderCount.utils.ts` + +```typescript +import { formatCompactNumber } from '@/utils/numberFormat.utils'; + +export interface HolderCountCopy { + value: string; + explanation: string; +} + +export function getFeaturedCreatorKeyHolderCopy( + count: number | null | undefined +): HolderCountCopy { + if (count == null) { + return { + value: 'Key holders unavailable', + explanation: 'Key holder data is not available yet.', + }; + } + if (count === 0) { + return { + value: 'No key holders yet', + explanation: + 'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.', + }; + } + return { + value: `${formatCompactNumber(count)} key holders`, + explanation: 'Number of wallets that currently hold at least one key.', + }; +} +``` + +### 4. `LandingPage.tsx` integration point + +Replace the inline `MiniStatChip` for "Audience" with the new component: + +```tsx +// Before + + +// After + +``` + +Where `realFetchHolderCount` is a thin wrapper over the eventual API call (currently returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` until the real endpoint exists). + +### 5. Test `createWrapper` helper + +**File:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + +```typescript +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} +``` + +--- + +## Data Models + +### Cache entry shape + +```typescript +// Query key tuple +type HolderCountKey = ['creator', string, 'holderCount']; + +// Cached value type +type HolderCountData = number | null; +``` + +Pre-seeding in tests uses `queryClient.setQueryData`: + +```typescript +queryClient.setQueryData( + ['creator', CREATOR_ID, 'holderCount'], + initialCount // number | null +); +``` + +### Mock fetch shape + +```typescript +const mockFetchHolderCount = vi.fn<(id: string) => Promise>(); +``` + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The project already has `fast-check` installed as a dev dependency (`"fast-check": "^4.6.0"` in `package.json`), which will be used for all property-based tests below. Each property test runs a minimum of 100 iterations. + +--- + +### Property 1: Initial render round-trip + +*For any* non-negative integer `initialCount`, when the React Query cache is pre-seeded with that count and the component renders without a network call, the DOM shall display exactly the string `getFeaturedCreatorKeyHolderCopy(initialCount).value`. + +**Validates: Requirements 1.1, 5.4** + +--- + +### Property 2: Stale-while-revalidate display stability + +*For any* non-negative integer `initialCount`, while the invalidation-triggered refetch is in-flight (the mock fetch has not yet resolved), the DOM shall continue to display the formatted string derived from `initialCount` and shall not show a blank value or an error state. + +**Validates: Requirements 2.3** + +--- + +### Property 3: Post-invalidation update round-trip + +*For any* pair of distinct non-negative integers `(initialCount, updatedCount)`, after the cache is pre-seeded with `initialCount`, `queryClient.invalidateQueries` is called, and the mock refetch resolves with `updatedCount`, the DOM shall display `getFeaturedCreatorKeyHolderCopy(updatedCount).value`, shall no longer display `getFeaturedCreatorKeyHolderCopy(initialCount).value`, and this transition shall occur within the same mounted component instance (no unmount–remount cycle). + +**Validates: Requirements 3.1, 3.2, 3.4** + +--- + +### Property 4: Format function round-trip + +*For any* non-negative integer `n`, the string `getFeaturedCreatorKeyHolderCopy(n).value` shall equal `"No key holders yet"` when `n === 0`, or `formatCompactNumber(n) + " key holders"` when `n > 0` — and this value shall be identical to what the `FeaturedCreatorAudienceChip` renders in the DOM when seeded with `n`. + +**Validates: Requirements 5.1, 5.4** + +--- + +## Error Handling + +| Scenario | Behavior | +|---|---| +| `fetchHolderCount` rejects | `useCreatorHolderCount` returns `isError: true`, `count: null`; chip displays `"Key holders unavailable"` | +| `count` is `null` from fetch | Chip displays `"Key holders unavailable"` | +| `count` is `0` | Chip displays `"No key holders yet"` | +| `queryClient.invalidateQueries` with non-matching key | No refetch triggered; mock fetch not called; display unchanged | +| Network timeout during test | Controlled by mock — test resolves or rejects on demand | + +The hook does not implement retry logic beyond React Query's defaults (`retry: 3`). For tests, retry is disabled (`retry: false` on the test-scoped `QueryClient`) to keep assertions deterministic. + +--- + +## Testing Strategy + +### Overview + +This feature uses a **dual testing approach**: + +- **Property-based tests** (via `fast-check`) for universal correctness properties — format round-trips, stale-while-revalidate stability, and post-invalidation update guarantees. +- **Example-based / edge-case tests** for concrete scenarios: zero count, null count, non-matching query key, reload-not-called assertion. + +### Test file + +**Path:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + +### Test setup pattern + +```typescript +import { QueryClient } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import fc from 'fast-check'; + +const CREATOR_ID = 'test-creator-42'; + +let queryClient: QueryClient; +let mockFetchHolderCount: ReturnType; + +beforeEach(() => { + // Fresh QueryClient per test — retry disabled for determinism + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockFetchHolderCount = vi.fn(); +}); + +afterEach(() => { + queryClient.clear(); +}); +``` + +### Property-based test outline + +```typescript +// Property 1: Initial render round-trip +it('renders the correct formatted string for any seeded count', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async count => { + // Feature: holder-count-cache-invalidation-test, Property 1: + // For any non-negative integer initialCount, DOM displays getFeaturedCreatorKeyHolderCopy(initialCount).value + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockFetchHolderCount = vi.fn(); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count); + + const { unmount } = render( + , + { wrapper: createWrapper(queryClient) } + ); + + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)).toBeInTheDocument(); + expect(mockFetchHolderCount).not.toHaveBeenCalled(); + unmount(); + }), + { numRuns: 100 } + ); +}); +``` + +```typescript +// Property 3: Post-invalidation update round-trip +it('displays the updated count after invalidation with the same component instance', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 999 }), + fc.integer({ min: 1000, max: 1_000_000 }), + async (initialCount, updatedCount) => { + // Feature: holder-count-cache-invalidation-test, Property 3: + // For any distinct (initialCount, updatedCount), post-invalidation DOM shows updatedCount + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockFetchHolderCount = vi.fn().mockResolvedValue(updatedCount); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {}); + const { unmount } = render( + , + { wrapper: createWrapper(queryClient) } + ); + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['creator', CREATOR_ID, 'holderCount'] }); + }); + + await waitFor(() => { + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(updatedCount).value)).toBeInTheDocument(); + }); + expect(screen.queryByText(getFeaturedCreatorKeyHolderCopy(initialCount).value)).not.toBeInTheDocument(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + unmount(); + } + ), + { numRuns: 100 } + ); +}); +``` + +```typescript +// Property 4: Format function round-trip (pure function — no render needed) +it('getFeaturedCreatorKeyHolderCopy produces the correct format for all non-negative integers', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10_000_000 }), count => { + // Feature: holder-count-cache-invalidation-test, Property 4: + // For any positive integer n, value === formatCompactNumber(n) + " key holders" + const { value } = getFeaturedCreatorKeyHolderCopy(count); + expect(value).toBe(`${formatCompactNumber(count)} key holders`); + }), + { numRuns: 200 } + ); +}); +``` + +### Edge-case and example tests + +| Test | Classification | Key assertion | +|---|---|---| +| `count = 0` renders "No key holders yet" | EDGE_CASE | `screen.getByText('No key holders yet')` | +| `count = null` renders "Key holders unavailable" | EDGE_CASE | `screen.getByText('Key holders unavailable')` | +| Non-matching query key does not call mockFetch | EDGE_CASE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` | +| Seeded cache — mock fetch call count is zero | EXAMPLE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` | +| Mock fetch called exactly once after invalidation | EXAMPLE | `expect(mockFetchHolderCount).toHaveBeenCalledTimes(1)` with `CREATOR_ID` | + +### Vitest configuration + +No changes needed to `vitest.config.ts` — existing `jsdom` environment and `@testing-library/jest-dom/vitest` setup are sufficient. `fast-check` is already a dev dependency. + +### Mocks required in test file + +```typescript +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ isMismatch: false, expectedChainName: 'Stellar Testnet' }), +})); +// framer-motion and other heavy dependencies mocked as in LandingPage.keyboard.test.tsx +// No vi.mock for courseService — it is NOT imported in this test file +``` diff --git a/.kiro/specs/holder-count-cache-invalidation-test/requirements.md b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md new file mode 100644 index 0000000..f245064 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md @@ -0,0 +1,85 @@ +# Requirements Document + +## Introduction + +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. + +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. + +## Glossary + +- **Creator_Detail_Page**: The section of `LandingPage` (and its composing components) that displays creator statistics including the holder count "Audience" chip. +- **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`. +- **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`. +- **Query_Key**: The array used to identify a cache entry, e.g. `['creator', creatorId]`. +- **QueryClient**: The TanStack Query client instance that owns the cache and coordinates fetches. +- **QueryClientProvider**: The React context provider that makes a `QueryClient` available to components under test. +- **Test_Wrapper**: A helper that wraps a component under test with all required providers (`QueryClientProvider`, `MemoryRouter`) so it renders in isolation. +- **Mock_Fetch**: A `vi.fn()` stub that replaces the real network call, returning controlled data for each invocation. +- **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. +- **Refetch**: The background network request that React Query fires after invalidation; in tests this is fulfilled by the `Mock_Fetch`. + +## Requirements + +### Requirement 1: Initial Holder Count Renders Correctly + +**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. + +#### Acceptance Criteria + +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. +2. WHEN the initial render completes without triggering a network call, THE `Mock_Fetch` SHALL have been called zero times. +3. IF the `initialCount` is `0`, THEN THE `Creator_Detail_Page` SHALL display `"No key holders yet"` in the holder count element. +4. IF the `initialCount` is `null`, THEN THE `Creator_Detail_Page` SHALL display `"Key holders unavailable"` in the holder count element. + +--- + +### Requirement 2: Cache Invalidation Triggers a Refetch + +**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. + +#### Acceptance Criteria + +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. +2. WHEN the invalidation-driven refetch executes, THE `Mock_Fetch` SHALL be called exactly once with the creator's identifier as a parameter. +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. +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. + +--- + +### Requirement 3: Updated Holder Count Renders After Refetch + +**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. + +#### Acceptance Criteria + +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. +2. WHEN the updated count is visible, THE `Creator_Detail_Page` SHALL NOT display the formatted string that was derived from `initialCount`. +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). +4. WHEN `updatedCount` differs from `initialCount`, THE display transition SHALL occur within the same mounted component instance, confirming no unmount–remount cycle was required. + +--- + +### Requirement 4: Test Isolation and No Side Effects + +**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. + +#### Acceptance Criteria + +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. +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. +3. WHEN a test completes, THE `Test_Wrapper` SHALL unmount cleanly without leaving dangling subscriptions or timers that could affect subsequent tests. +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. + +--- + +### Requirement 5: Holder Count Display Format Consistency + +**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. + +#### Acceptance Criteria + +1. THE `Creator_Detail_Page` SHALL format a positive `holderCount` as `" key holders"` where `` is the output of `formatCompactNumber(holderCount)`. +2. WHEN `holderCount` is `0`, THE `Creator_Detail_Page` SHALL display exactly `"No key holders yet"`. +3. WHEN `holderCount` is `null` or `undefined`, THE `Creator_Detail_Page` SHALL display exactly `"Key holders unavailable"`. +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). diff --git a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md new file mode 100644 index 0000000..be1ab07 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md @@ -0,0 +1,107 @@ +# Implementation Plan: Holder Count Cache Invalidation Test + +## Overview + +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. + +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. + +## Tasks + +- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module + - Create `src/utils/holderCount.utils.ts` + - Move the `getFeaturedCreatorKeyHolderCopy` function (currently defined inline in `LandingPage.tsx` at line ~81) into the new file + - Export `HolderCountCopy` interface and `getFeaturedCreatorKeyHolderCopy` function + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` + - Keep the existing inline definition in `LandingPage.tsx` for now — it will be replaced in Task 4 + - _Requirements: 5.1, 5.2, 5.3, 5.4_ + +- [x] 2. Create `useCreatorHolderCount` hook + - Create `src/hooks/useCreatorHolderCount.ts` + - Implement `useQuery` with query key `['creator', creatorId, 'holderCount']` and `staleTime: 30_000` + - Accept `fetchHolderCount: (id: string) => Promise` as an injected parameter (avoids module-level `vi.mock` in tests) + - Export `HolderCountResult` interface `{ count: number | null; isLoading: boolean; isError: boolean }` + - Return `{ count: data ?? null, isLoading, isError }` + - _Requirements: 2.1, 2.2, 2.3_ + +- [x] 3. Create `FeaturedCreatorAudienceChip` component + - Create `src/components/common/FeaturedCreatorAudienceChip.tsx` + - Accept props: `creatorId: string` and `fetchHolderCount: (id: string) => Promise` + - Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy` + - Render `` + - Import `MiniStatChip` from `@/components/common/MiniStatChip` + - Import `useCreatorHolderCount` from `@/hooks/useCreatorHolderCount` + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` + - _Requirements: 1.1, 1.3, 1.4, 3.1, 3.2, 5.1, 5.2, 5.3_ + +- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip` + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` + - Replace the inline `` block (lines ~1199–1205) with `` + - Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands) + - Remove the now-unused `featuredCreatorKeyHolderCopy` derived variable (line ~560–563) and the inline `getFeaturedCreatorKeyHolderCopy` function definition (lines ~81–100) + - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes + - _Requirements: 1.1, 3.4_ + +- [-] 5. Write the integration test + - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + - [-] 5.1 Set up test scaffolding + - 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` + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` + - 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` + - Define `CREATOR_ID = 'test-creator-42'`; declare `queryClient` and `mockFetchHolderCount` at describe scope + - `beforeEach`: create fresh `QueryClient({ defaultOptions: { queries: { retry: false } } })` and reset `mockFetchHolderCount` via `vi.fn()` + - `afterEach`: call `queryClient.clear()` + - Implement `createWrapper(queryClient)` returning a component that wraps children in `` + `` + - _Requirements: 4.1, 4.2, 4.3, 4.4_ + + - [~] 5.2 Write property test for Property 1 — initial render round-trip + - **Property 1: Initial render round-trip** + - **Validates: Requirements 1.1, 5.4** + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` + - 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()` + - _Requirements: 1.1, 1.2, 5.4_ + + - [~] 5.3 Write property test for Property 2 — stale-while-revalidate display stability + - **Property 2: Stale-while-revalidate display stability** + - **Validates: Requirements 2.3** + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` + - 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 + - _Requirements: 2.3_ + + - [~] 5.4 Write property test for Property 3 — post-invalidation update round-trip + - **Property 3: Post-invalidation update round-trip** + - **Validates: Requirements 3.1, 3.2, 3.4** + - 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`) + - 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()` + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [~] 5.5 Write property test for Property 4 — format function round-trip + - **Property 4: Format function round-trip** + - **Validates: Requirements 5.1, 5.4** + - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200` + - For each `n > 0`: assert `getFeaturedCreatorKeyHolderCopy(n).value === formatCompactNumber(n) + ' key holders'` + - _Requirements: 5.1, 5.4_ + + - [ ]* 5.6 Write edge-case tests + - `count = 0` renders `"No key holders yet"` — seed cache with `0`, render, assert text present + - `count = null` renders `"Key holders unavailable"` — seed cache with `null`, render, assert text present + - Non-matching query key: invalidate a different key, assert `mockFetchHolderCount` was NOT called and display is unchanged + - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID` + - _Requirements: 1.3, 1.4, 2.2, 2.4_ + +- [~] 6. Checkpoint — run tests and confirm everything passes + - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/` + - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests + - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes) + - Fix any TypeScript or test errors surfaced; ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP +- Each task references specific requirements for traceability +- The `fetchHolderCount` injection pattern in the hook and component avoids `vi.mock` hoisting complexity — tests pass `vi.fn()` directly as a prop +- Property tests use disjoint integer ranges in Property 3 to guarantee `initialCount !== updatedCount` without needing a `fc.filter` +- `retry: false` on the test-scoped `QueryClient` keeps assertions deterministic +- `fast-check` v4 (`"^4.6.0"`) is already installed as a dev dependency — no new packages needed diff --git a/src/components/common/FeaturedCreatorAudienceChip.tsx b/src/components/common/FeaturedCreatorAudienceChip.tsx new file mode 100644 index 0000000..d8dc436 --- /dev/null +++ b/src/components/common/FeaturedCreatorAudienceChip.tsx @@ -0,0 +1,24 @@ +import MiniStatChip from '@/components/common/MiniStatChip'; +import { useCreatorHolderCount } from '@/hooks/useCreatorHolderCount'; +import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils'; + +interface FeaturedCreatorAudienceChipProps { + creatorId: string; + fetchHolderCount: (id: string) => Promise; +} + +export function FeaturedCreatorAudienceChip({ + creatorId, + fetchHolderCount, +}: FeaturedCreatorAudienceChipProps) { + const { count } = useCreatorHolderCount(creatorId, fetchHolderCount); + const copy = getFeaturedCreatorKeyHolderCopy(count); + + return ( + + ); +} diff --git a/src/hooks/useCreatorHolderCount.ts b/src/hooks/useCreatorHolderCount.ts new file mode 100644 index 0000000..ff4d14f --- /dev/null +++ b/src/hooks/useCreatorHolderCount.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +export interface HolderCountResult { + count: number | null; + isLoading: boolean; + isError: boolean; +} + +/** + * Fetches the holder count for a given creator via React Query. + * Query key: ['creator', creatorId, 'holderCount'] + * + * The queryFn is injected as a parameter so tests can supply a mock + * without module-level vi.mock() patching. + */ +export function useCreatorHolderCount( + creatorId: string, + fetchHolderCount: (id: string) => Promise +): HolderCountResult { + const { data, isLoading, isError } = useQuery({ + queryKey: ['creator', creatorId, 'holderCount'], + queryFn: () => fetchHolderCount(creatorId), + staleTime: 30_000, + }); + + return { + count: data ?? null, + isLoading, + isError, + }; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index b61b52c..0b65b83 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -23,6 +23,7 @@ import CompactSectionSubtitle from '@/components/common/CompactSectionSubtitle'; import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow'; import MiniStatChip from '@/components/common/MiniStatChip'; +import { FeaturedCreatorAudienceChip } from '@/components/common/FeaturedCreatorAudienceChip'; import MarketplaceSection from '@/components/common/MarketplaceSection'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; @@ -78,28 +79,6 @@ const FEATURED_CREATOR_FACTS = [ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null; const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0; -const getFeaturedCreatorKeyHolderCopy = (count: number | null) => { - if (count == null) { - return { - value: 'Key holders unavailable', - explanation: 'Key holder data is not available yet.', - }; - } - - if (count === 0) { - return { - value: 'No key holders yet', - explanation: - 'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.', - }; - } - - return { - value: `${formatCompactNumber(count)} key holders`, - explanation: 'Number of wallets that currently hold at least one key.', - }; -}; - // Fallback demo data in case API fails const DEMO_CREATORS: Course[] = [ { @@ -557,10 +536,6 @@ function LandingPage() { const start = safePage * PAGE_SIZE; return filteredCreators.slice(start, start + PAGE_SIZE); }, [filteredCreators, safePage]); - const featuredCreatorKeyHolderCopy = getFeaturedCreatorKeyHolderCopy( - FEATURED_CREATOR_KEY_HOLDER_COUNT - ); - // Choose the featured creator from live data when available, otherwise // fall back to the demo featured creator. This keeps the profile panel // reactive to backend updates (supply, price, etc.). @@ -1195,12 +1170,9 @@ function LandingPage() { value="Verified creator" explanation="Creator has completed identity verification with Access Layer." /> - Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)} /> ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, layout, transition, ...props }: Record & { children?: ReactNode }) => { + void layout; + void transition; + return React.createElement('div', props as Record, children); + }, + button: ({ children, ...props }: Record & { children?: ReactNode }) => + React.createElement('button', props as Record, children), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Test constants & helpers +// --------------------------------------------------------------------------- + +const CREATOR_ID = 'test-creator-42'; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function makeFreshQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('FeaturedCreatorAudienceChip — holder count cache invalidation', () => { + let queryClient: QueryClient; + let mockFetchHolderCount: ReturnType Promise>>; + + beforeEach(() => { + queryClient = makeFreshQueryClient(); + mockFetchHolderCount = vi.fn<(id: string) => Promise>(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + // ------------------------------------------------------------------------- + // Property 1: Initial render round-trip + // For any seeded integer count, the DOM displays getFeaturedCreatorKeyHolderCopy(count).value + // with zero calls to the mock fetch. + // Validates: Requirements 1.1, 1.2, 5.4 + // ------------------------------------------------------------------------- + it('Property 1 — initial render round-trip: displays seeded count without calling fetch', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async (count) => { + const localClient = makeFreshQueryClient(); + const localMock = vi.fn<(id: string) => Promise>(); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + const expectedText = getFeaturedCreatorKeyHolderCopy(count).value; + expect(screen.getByText(expectedText)).toBeInTheDocument(); + expect(localMock).not.toHaveBeenCalled(); + + unmount(); + localClient.clear(); + }), + { numRuns: 100 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 2: Stale-while-revalidate display stability + // While a pending refetch has not yet resolved, the old value remains visible. + // Validates: Requirement 2.3 + // ------------------------------------------------------------------------- + it('Property 2 — stale-while-revalidate: old value stays visible while refetch is in-flight', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async (initialCount) => { + const localClient = makeFreshQueryClient(); + // A fetch that never resolves during this test window + const neverResolvingFetch = vi.fn<(id: string) => Promise>( + () => new Promise(() => { /* intentionally never resolves */ }), + ); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + // Trigger invalidation but do not await refetch resolution + await act(async () => { + await localClient.invalidateQueries({ + queryKey: ['creator', CREATOR_ID, 'holderCount'], + }); + }); + + // Old value must still be visible (stale-while-revalidate) + const oldText = getFeaturedCreatorKeyHolderCopy(initialCount).value; + expect(screen.getByText(oldText)).toBeInTheDocument(); + // No error or blank state + expect(screen.queryByText('Key holders unavailable')).not.toBeInTheDocument(); + + unmount(); + localClient.clear(); + }), + { numRuns: 50 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 3: Post-invalidation update round-trip + // After invalidation + resolved refetch, updated count is shown; old is gone; + // no page reload; same component instance. + // Validates: Requirements 3.1, 3.2, 3.3, 3.4 + // ------------------------------------------------------------------------- + it('Property 3 — post-invalidation update: new count shown, old gone, no page reload', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 999 }), + fc.integer({ min: 1000, max: 1_000_000 }), + async (initialCount, updatedCount) => { + const localClient = makeFreshQueryClient(); + const localMock = vi.fn<(id: string) => Promise>() + .mockResolvedValue(updatedCount); + + localClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const reloadSpy = vi + .spyOn(window.location, 'reload') + .mockImplementation(() => { /* noop */ }); + + const { unmount } = render( + , + { wrapper: createWrapper(localClient) }, + ); + + // Invalidate and allow refetch to resolve + await act(async () => { + await localClient.invalidateQueries({ + queryKey: ['creator', CREATOR_ID, 'holderCount'], + }); + }); + + const updatedText = getFeaturedCreatorKeyHolderCopy(updatedCount).value; + const initialText = getFeaturedCreatorKeyHolderCopy(initialCount).value; + + await waitFor(() => { + expect(screen.getByText(updatedText)).toBeInTheDocument(); + }); + + expect(screen.queryByText(initialText)).not.toBeInTheDocument(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + unmount(); + localClient.clear(); + }, + ), + { numRuns: 100 }, + ); + }); + + // ------------------------------------------------------------------------- + // Property 4: Format function round-trip (pure function — no render needed) + // For any positive integer n, getFeaturedCreatorKeyHolderCopy(n).value === + // formatCompactNumber(n) + ' key holders' + // Validates: Requirements 5.1, 5.4 + // ------------------------------------------------------------------------- + it('Property 4 — format round-trip: getFeaturedCreatorKeyHolderCopy is consistent with formatCompactNumber', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10_000_000 }), (n) => { + const { value } = getFeaturedCreatorKeyHolderCopy(n); + expect(value).toBe(`${formatCompactNumber(n)} key holders`); + }), + { numRuns: 200 }, + ); + }); + + // ------------------------------------------------------------------------- + // Edge cases + // ------------------------------------------------------------------------- + + it('edge case — count = 0: renders "No key holders yet"', () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 0); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + expect(screen.getByText('No key holders yet')).toBeInTheDocument(); + }); + + it('edge case — count = null: renders "Key holders unavailable"', () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], null); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + expect(screen.getByText('Key holders unavailable')).toBeInTheDocument(); + }); + + it('edge case — non-matching query key: invalidation does not call mockFetch', async () => { + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 42); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: ['creator', 'different-creator-id', 'holderCount'], + }); + }); + + expect(mockFetchHolderCount).not.toHaveBeenCalled(); + // Display unchanged + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(42).value)).toBeInTheDocument(); + }); + + it('edge case — after invalidation: mockFetch called exactly once with CREATOR_ID', async () => { + const updatedCount = 99; + mockFetchHolderCount.mockResolvedValue(updatedCount); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], 42); + + render( + , + { wrapper: createWrapper(queryClient) }, + ); + + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: ['creator', CREATOR_ID, 'holderCount'], + }); + }); + + await waitFor(() => { + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(updatedCount).value)).toBeInTheDocument(); + }); + + expect(mockFetchHolderCount).toHaveBeenCalledTimes(1); + expect(mockFetchHolderCount).toHaveBeenCalledWith(CREATOR_ID); + }); +}); diff --git a/src/utils/holderCount.utils.ts b/src/utils/holderCount.utils.ts new file mode 100644 index 0000000..4f35ca9 --- /dev/null +++ b/src/utils/holderCount.utils.ts @@ -0,0 +1,28 @@ +import { formatCompactNumber } from '@/utils/numberFormat.utils'; + +export interface HolderCountCopy { + value: string; + explanation: string; +} + +export function getFeaturedCreatorKeyHolderCopy( + count: number | null | undefined +): HolderCountCopy { + if (count == null) { + return { + value: 'Key holders unavailable', + explanation: 'Key holder data is not available yet.', + }; + } + if (count === 0) { + return { + value: 'No key holders yet', + explanation: + 'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.', + }; + } + return { + value: `${formatCompactNumber(count)} key holders`, + explanation: 'Number of wallets that currently hold at least one key.', + }; +}