Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "a3f82c1e-9d47-4b8e-bc63-7e5a2f3d1094", "workflowType": "requirements-first", "specType": "feature"}
435 changes: 435 additions & 0 deletions .kiro/specs/holder-count-cache-invalidation-test/design.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions .kiro/specs/holder-count-cache-invalidation-test/requirements.md
Original file line number Diff line number Diff line change
@@ -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 `"<compactNumber> key holders"` where `<compactNumber>` 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).
107 changes: 107 additions & 0 deletions .kiro/specs/holder-count-cache-invalidation-test/tasks.md
Original file line number Diff line number Diff line change
@@ -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<number | null>` 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<number | null>`
- Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy`
- Render `<MiniStatChip label="Audience" value={copy.value} explanation={copy.explanation} />`
- 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 `<MiniStatChip label="Audience" …>` block (lines ~1199–1205) with `<FeaturedCreatorAudienceChip creatorId={featuredCreator.id} fetchHolderCount={...} />`
- 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 `<QueryClientProvider>` + `<MemoryRouter>`
- _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
24 changes: 24 additions & 0 deletions src/components/common/FeaturedCreatorAudienceChip.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>;
}

export function FeaturedCreatorAudienceChip({
creatorId,
fetchHolderCount,
}: FeaturedCreatorAudienceChipProps) {
const { count } = useCreatorHolderCount(creatorId, fetchHolderCount);
const copy = getFeaturedCreatorKeyHolderCopy(count);

return (
<MiniStatChip
label="Audience"
value={copy.value}
explanation={copy.explanation}
/>
);
}
31 changes: 31 additions & 0 deletions src/hooks/useCreatorHolderCount.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>
): HolderCountResult {
const { data, isLoading, isError } = useQuery({
queryKey: ['creator', creatorId, 'holderCount'],
queryFn: () => fetchHolderCount(creatorId),
staleTime: 30_000,
});

return {
count: data ?? null,
isLoading,
isError,
};
}
36 changes: 4 additions & 32 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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.).
Expand Down Expand Up @@ -1195,12 +1170,9 @@ function LandingPage() {
value="Verified creator"
explanation="Creator has completed identity verification with Access Layer."
/>
<MiniStatChip
label="Audience"
value={featuredCreatorKeyHolderCopy.value}
explanation={
featuredCreatorKeyHolderCopy.explanation
}
<FeaturedCreatorAudienceChip
creatorId="featured-creator"
fetchHolderCount={() => Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)}
/>
<MiniStatChip
label="Access"
Expand Down
Loading
Loading