Skip to content
Merged
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
Expand Up @@ -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(<CreatorProfileErrorState />);

expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Unable to load this creator profile')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
});
});
80 changes: 80 additions & 0 deletions src/hooks/__tests__/tradeCacheInvalidation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 11 additions & 1 deletion src/hooks/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
}
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -133,6 +134,15 @@ const mockMatchMedia = () => {
const getCreatorTitles = () =>
screen.getAllByRole('article').map(node => node.textContent);

function RouteLocationTracker() {
const location = useLocation();
return <div data-testid="location-search">{location.search}</div>;
}

function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}

describe('LandingPage debounced search clear integration (#519)', () => {
beforeEach(() => {
mockMatchMedia();
Expand All @@ -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(
<MemoryRouter>
<LandingPage />
</MemoryRouter>
<QueryClientProvider client={makeQueryClient()}>
<MemoryRouter>
<LandingPage />
</MemoryRouter>
</QueryClientProvider>
);

await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1));
Expand Down Expand Up @@ -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(
<QueryClientProvider client={makeQueryClient()}>
<MemoryRouter initialEntries={['/?search=Beta']}>
<LandingPage />
<RouteLocationTracker />
</MemoryRouter>
</QueryClientProvider>
);

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('');
});
});
});
39 changes: 35 additions & 4 deletions src/pages/__tests__/LandingPage.sort.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -150,6 +151,10 @@ function RouteLocationTracker() {
return <div data-testid="location-search">{location.search}</div>;
}

function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}

describe('LandingPage sort dropdown integration test', () => {
beforeEach(() => {
mockMatchMedia();
Expand All @@ -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(
<MemoryRouter>
<LandingPage />
<RouteLocationTracker />
</MemoryRouter>
<QueryClientProvider client={makeQueryClient()}>
<MemoryRouter>
<LandingPage />
<RouteLocationTracker />
</MemoryRouter>
</QueryClientProvider>
);

// Initial load gets courses in featured order
Expand Down Expand Up @@ -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(
<QueryClientProvider client={makeQueryClient()}>
<MemoryRouter initialEntries={['/?sort=price-asc']}>
<LandingPage />
<RouteLocationTracker />
</MemoryRouter>
</QueryClientProvider>
);

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