From dfed6dbbc7603dae77c6f6c5b9948916c66359c4 Mon Sep 17 00:00:00 2001 From: KingDavid9999 Date: Fri, 16 Oct 2026 12:21:10 -0700 Subject: [PATCH] feat(key-detail): scope buy cooldown fetch to the connected wallet Fetch the trade cooldown per connected wallet so the buy button and countdown on the key detail page reflect the wallet actually in use. - queryKeys: tradeCooldown accepts an optional wallet to scope the cache - useTradeCooldownStatus: take the wallet address, refetch on wallet connect/switch, and skip the fetch when no wallet is present - CreatorDetailPage: pass the connected address into the hook - tests: cover no-wallet, fetch-on-connect, and no cooldown leaking between wallets; repair the stale page integration test mock Closes #915 --- .../__tests__/useTradeCooldownStatus.test.tsx | 62 +++++++++++++++++-- src/hooks/useTradeCooldownStatus.ts | 17 +++-- src/lib/queryKeys.ts | 8 ++- src/pages/CreatorDetailPage.tsx | 5 +- ...ailPage.tradeCooldown.integration.test.tsx | 24 +++++-- 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/hooks/__tests__/useTradeCooldownStatus.test.tsx b/src/hooks/__tests__/useTradeCooldownStatus.test.tsx index 38744abf..e9819df2 100644 --- a/src/hooks/__tests__/useTradeCooldownStatus.test.tsx +++ b/src/hooks/__tests__/useTradeCooldownStatus.test.tsx @@ -47,7 +47,7 @@ describe('useTradeCooldownStatus (#998)', () => { }); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); @@ -58,16 +58,70 @@ describe('useTradeCooldownStatus (#998)', () => { }); it('is disabled for an empty key id', () => { - renderHook(() => useTradeCooldownStatus(''), { wrapper }); + renderHook(() => useTradeCooldownStatus('', 'GWALLET1'), { wrapper }); expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); }); + it('does not fetch when no wallet is connected (#915)', () => { + renderHook(() => useTradeCooldownStatus('creator-1', undefined), { + wrapper, + }); + + expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); + }); + + it('fetches when a wallet connects after page load (#915)', async () => { + mockGetTradeCooldownStatus.mockResolvedValue({ + keyId: 'creator-1', + nextBuyAllowedAt: Math.floor(Date.now() / 1000) + 60, + }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet?: string }) => + useTradeCooldownStatus('creator-1', wallet), + { wrapper, initialProps: { wallet: undefined } as { wallet?: string } } + ); + + expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); + + rerender({ wallet: 'GWALLET1' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGetTradeCooldownStatus).toHaveBeenCalledTimes(1); + }); + + it('refetches for a new wallet and never shows the previous wallet cooldown (#915)', async () => { + const future = Math.floor(Date.now() / 1000) + 120; + mockGetTradeCooldownStatus + .mockResolvedValueOnce({ keyId: 'creator-1', nextBuyAllowedAt: future }) + .mockResolvedValueOnce({ keyId: 'creator-1', nextBuyAllowedAt: null }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet: string }) => + useTradeCooldownStatus('creator-1', wallet), + { wrapper, initialProps: { wallet: 'GWALLET1' } } + ); + + await waitFor(() => + expect(result.current.data?.nextBuyAllowedAt).toBe(future) + ); + + rerender({ wallet: 'GWALLET2' }); + + // Wallet 1's cooldown must not leak into wallet 2 while it loads. + expect(result.current.data).toBeUndefined(); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.nextBuyAllowedAt).toBeNull(); + expect(mockGetTradeCooldownStatus).toHaveBeenCalledTimes(2); + }); + it('treats a 404 (no cooldown endpoint) as null rather than an error', async () => { mockGetTradeCooldownStatus.mockResolvedValue(null); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); @@ -82,7 +136,7 @@ describe('useTradeCooldownStatus (#998)', () => { }); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); diff --git a/src/hooks/useTradeCooldownStatus.ts b/src/hooks/useTradeCooldownStatus.ts index 64165133..611d6c5d 100644 --- a/src/hooks/useTradeCooldownStatus.ts +++ b/src/hooks/useTradeCooldownStatus.ts @@ -17,26 +17,31 @@ import { isActiveCooldown } from '@/utils/tradeCooldown.utils'; export type { TradeCooldownInfo }; /** - * Fetches the trade cooldown status for a creator key (#998). + * Fetches the trade cooldown status for a creator key and the connected + * wallet (#998, #915). * - * The status is fetched on page load (React Query's default fetch-on-mount) - * and refreshed after every completed transaction: `invalidateTradeCooldownStatus` + * The cache is scoped per wallet, so the status is fetched on page load and + * again whenever a wallet connects or switches — a previous wallet's cooldown + * is never shown for the new one. With no wallet connected there is no + * per-wallet cooldown, so nothing is fetched. It is refreshed after every + * completed transaction: `invalidateTradeCooldownStatus` * is exported so trade mutations can drop the cached cooldown and force a * refetch, which is how buttons re-enable as soon as the backend reports the * cooldown has lifted. */ export function useTradeCooldownStatus( - keyId: string + keyId: string, + walletAddress?: string | null ): UseQueryResult { return useQuery({ - queryKey: queryKeys.creators.tradeCooldown(keyId), + queryKey: queryKeys.creators.tradeCooldown(keyId, walletAddress ?? ''), queryFn: async () => { // The endpoint has no per-wallet identity in the payload — the // creatorId is the key we asked about. const info = await courseService.getTradeCooldownStatus(keyId); return info ? { ...info, creatorId: keyId } : null; }, - enabled: Boolean(keyId), + enabled: Boolean(keyId) && Boolean(walletAddress), staleTime: 15_000, retry: false, }); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index d7b8d133..b9fa31a7 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -54,8 +54,12 @@ export const queryKeys = { ['creators', creatorId, 'oracle-price'] as const, performanceBond: (creatorId: string) => ['creators', creatorId, 'performance-bond'] as const, - tradeCooldown: (creatorId: string) => - ['creators', creatorId, 'trade-cooldown'] as const, + // Optional wallet scopes the cache per connected wallet (#915); omit it + // to address every wallet's entry for the key (e.g. invalidation). + tradeCooldown: (creatorId: string, wallet?: string) => + wallet + ? (['creators', creatorId, 'trade-cooldown', wallet] as const) + : (['creators', creatorId, 'trade-cooldown'] as const), whitelist: (creatorId: string) => ['creators', creatorId, 'whitelist'] as const, publicProfile: (address: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index fc767eb1..63a8dd38 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -287,7 +287,10 @@ function CreatorDetailPageContent() { const tradeMutation = useTradeMutation(connectedWalletAddress ?? ''); - const { data: tradeCooldownStatus } = useTradeCooldownStatus(id || ''); + const { data: tradeCooldownStatus } = useTradeCooldownStatus( + id || '', + userAddress + ); const tradeCooldown: ActiveTradeCooldown | null = resolveActiveTradeCooldown( tradeCooldownStatus, diff --git a/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx index 9136b4db..e14e78a2 100644 --- a/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx +++ b/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx @@ -7,10 +7,26 @@ import { MemoryRouter, Route, Routes } from 'react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import showToast from '@/utils/toast.util'; -vi.mock('@/hooks/useCreators', () => ({ - useCreatorDetail: vi.fn(), - useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), -})); +vi.mock('@/hooks/useCreators', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +// A connected wallet: the cooldown is fetched per connected wallet (#915). +vi.mock('wagmi', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useAccount: vi.fn(() => ({ + address: 'GBUYERWALLET', + isConnected: true, + })), + }; +}); vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ useCreatorProfileStaleIndicator: () => ({