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'])
+ );
+ });
});