diff --git a/src/components/common/__tests__/CreatorProfileErrorState.test.tsx b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx index 8c595f8..e9a91b3 100644 --- a/src/components/common/__tests__/CreatorProfileErrorState.test.tsx +++ b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx @@ -48,4 +48,12 @@ describe('CreatorProfileErrorState (#573)', () => { const retryButton = screen.getByRole('button', { name: /retrying\.\.\./i }); expect(retryButton).toBeDisabled(); }); + + it('renders without error when onRetry is not provided', () => { + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Unable to load this creator profile')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/hooks/__tests__/tradeCacheInvalidation.test.ts b/src/hooks/__tests__/tradeCacheInvalidation.test.ts new file mode 100644 index 0000000..635d19d --- /dev/null +++ b/src/hooks/__tests__/tradeCacheInvalidation.test.ts @@ -0,0 +1,80 @@ +/** + * Unit test for structured cache invalidation log after a confirmed trade (#636). + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTradeMutation } from '../useWallet'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +describe('useTradeMutation cache invalidation log (#636)', () => { + it('emits a structured debug log after trade settlement in non-test environment', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper }); + + result.current.mutate({ + creatorId: 'creator-1', + amount: 3, + priceStroops: 1_000_000, + price: 0.1, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 }); + + expect(debugSpy).toHaveBeenCalledWith( + '[cache-invalidation]', + expect.objectContaining({ + invalidated_keys: expect.arrayContaining([expect.any(String)]), + trigger: 'buy', + creator_id: 'creator-1', + invalidated_at: expect.any(String), + }), + ); + + debugSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + + it('does not emit the log in test environment', async () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper }); + + result.current.mutate({ + creatorId: 'creator-2', + amount: -1, + priceStroops: 500_000, + price: 0.05, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 }); + + expect(debugSpy).not.toHaveBeenCalled(); + + debugSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 849e4c6..506dd62 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -90,8 +90,18 @@ export function useTradeMutation(address: string) { `Holdings refreshed: +${variables.amount} keys.` ); }, - onSettled: () => { + onSettled: (_data, _error, variables) => { + const invalidatedKeys = [queryKeys.wallet.holdings(address)]; queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) }); + + if (process.env.NODE_ENV !== 'test') { + console.debug('[cache-invalidation]', { + invalidated_keys: invalidatedKeys.map(k => JSON.stringify(k)), + trigger: (variables as TradeVariables).amount > 0 ? 'buy' : 'sell', + creator_id: (variables as TradeVariables).creatorId, + invalidated_at: new Date().toISOString(), + }); + } }, }); diff --git a/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx index 8beaf63..24ebbdd 100644 --- a/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx +++ b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx @@ -6,7 +6,8 @@ */ import type { ComponentProps, ReactNode } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import LandingPage from '@/pages/LandingPage'; import { @@ -133,6 +134,15 @@ const mockMatchMedia = () => { const getCreatorTitles = () => screen.getAllByRole('article').map(node => node.textContent); +function RouteLocationTracker() { + const location = useLocation(); + return
{location.search}
; +} + +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + describe('LandingPage debounced search clear integration (#519)', () => { beforeEach(() => { mockMatchMedia(); @@ -147,9 +157,11 @@ describe('LandingPage debounced search clear integration (#519)', () => { it('re-fetches without a search param and restores the full creator list after the input is cleared', async () => { render( - - - + + + + + ); await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); @@ -177,4 +189,30 @@ describe('LandingPage debounced search clear integration (#519)', () => { expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) ); }); + + it('removes search param from the URL and resets to page one after clearing', async () => { + render( + + + + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'Beta' }); + + const input = screen.getByPlaceholderText( + /search creators by name or handle/i + ); + fireEvent.change(input, { target: { value: '' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + + await waitFor(() => { + expect(screen.getByTestId('location-search')).toHaveTextContent(''); + }); + }); }); diff --git a/src/pages/__tests__/LandingPage.sort.integration.test.tsx b/src/pages/__tests__/LandingPage.sort.integration.test.tsx index d8ceda5..af9f3f9 100644 --- a/src/pages/__tests__/LandingPage.sort.integration.test.tsx +++ b/src/pages/__tests__/LandingPage.sort.integration.test.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import LandingPage from '@/pages/LandingPage'; import { @@ -150,6 +151,10 @@ function RouteLocationTracker() { return
{location.search}
; } +function makeQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + describe('LandingPage sort dropdown integration test', () => { beforeEach(() => { mockMatchMedia(); @@ -164,10 +169,12 @@ describe('LandingPage sort dropdown integration test', () => { it('selects sort and reorders creator list to match API response, updating the URL query string', async () => { render( - - - - + + + + + + ); // Initial load gets courses in featured order @@ -199,4 +206,28 @@ describe('LandingPage sort dropdown integration test', () => { expect(screen.getByTestId('location-search')).toHaveTextContent('sort=price-asc') ); }); + + it('initialises sort dropdown from URL param and fetches with that sort on load', async () => { + render( + + + + + + + ); + + // Assert initial fetch uses the sort param from URL + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ sort: 'price-asc' }); + + // Assert dropdown shows the correct selected option + const dropdown = screen.getByLabelText(/^sort$/i) as HTMLSelectElement; + expect(dropdown.value).toBe('price-asc'); + + // Assert list renders results matching the price-sorted response + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Beta', 'Creator Gamma', 'Creator Alpha']) + ); + }); });