diff --git a/src/components/property/__tests__/PropertyMapView.test.tsx b/src/components/property/__tests__/PropertyMapView.test.tsx new file mode 100644 index 00000000..b43da7f6 --- /dev/null +++ b/src/components/property/__tests__/PropertyMapView.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import PropertyMapView from '@/components/property/PropertyMapView'; + +jest.mock('react-leaflet', () => ({ + MapContainer: ({ children }: any) =>
{children}
, + TileLayer: () =>
, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, +})); + +jest.mock('react-leaflet-cluster', () => ({ + default: ({ children }: any) =>
{children}
, +})); + +const mockProperties = [ + { + id: 'prop-1', + lat: 40.7128, + lng: -74.006, + price: 500000, + address: '1 Wall St, New York', + }, + { + id: 'prop-2', + lat: 40.758, + lng: -73.9855, + price: 750000, + address: '5 Times Sq, New York', + }, +]; + +describe('PropertyMapView', () => { + it('renders the map container and tile layer', () => { + render(); + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.getByTestId('tile-layer')).toBeInTheDocument(); + }); + + it('renders a marker for every property fixture', () => { + render(); + expect(screen.getAllByTestId('map-marker')).toHaveLength(2); + }); + + it('shows the price and address in the marker popup', () => { + render(); + expect(screen.getByText('$500,000')).toBeInTheDocument(); + expect(screen.getByText('1 Wall St, New York')).toBeInTheDocument(); + expect(screen.getByText('$750,000')).toBeInTheDocument(); + expect(screen.getByText('5 Times Sq, New York')).toBeInTheDocument(); + }); + + it('renders no markers when there are no properties', () => { + render(); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + // The map container itself still renders. + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.getByTestId('marker-cluster')).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useGestures.test.ts b/src/hooks/__tests__/useGestures.test.ts new file mode 100644 index 00000000..6a4d4a26 --- /dev/null +++ b/src/hooks/__tests__/useGestures.test.ts @@ -0,0 +1,162 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { useGestures } from '../useGestures'; + +type GestureHandlers = { + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + onSwipeUp?: () => void; + onSwipeDown?: () => void; + onPinch?: (scale: number) => void; + onDoubleTap?: () => void; + onLongPress?: () => void; +}; + +const Harness = ({ handlers, options }: { handlers: GestureHandlers; options?: Record }) => { + const ref = useGestures(handlers, options); + return
} data-testid="gesture-area" />; +}; + +const touch = (clientX: number, clientY: number) => ({ clientX, clientY }); + +describe('useGestures', () => { + it('fires onSwipeLeft when a left swipe crosses the threshold', () => { + const handlers = { onSwipeLeft: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(100, 100)] }); + fireEvent.touchMove(el, { touches: [touch(20, 100)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onSwipeLeft).toHaveBeenCalledTimes(1); + }); + + it('fires onSwipeRight when a right swipe crosses the threshold', () => { + const handlers = { onSwipeRight: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(20, 100)] }); + fireEvent.touchMove(el, { touches: [touch(120, 100)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onSwipeRight).toHaveBeenCalledTimes(1); + }); + + it('fires onSwipeUp for an upward swipe', () => { + const handlers = { onSwipeUp: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(100, 150)] }); + fireEvent.touchMove(el, { touches: [touch(100, 40)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onSwipeUp).toHaveBeenCalledTimes(1); + }); + + it('fires onSwipeDown for a downward swipe', () => { + const handlers = { onSwipeDown: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(100, 40)] }); + fireEvent.touchMove(el, { touches: [touch(100, 150)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onSwipeDown).toHaveBeenCalledTimes(1); + }); + + it('does not fire a swipe for movement below the threshold', () => { + const handlers = { onSwipeLeft: jest.fn(), onDoubleTap: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(100, 100)] }); + fireEvent.touchMove(el, { touches: [touch(110, 100)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onSwipeLeft).not.toHaveBeenCalled(); + }); + + it('fires onDoubleTap for two quick taps', () => { + const handlers = { onDoubleTap: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + // Tap 1 + fireEvent.touchStart(el, { touches: [touch(50, 50)] }); + fireEvent.touchEnd(el, { touches: [] }); + // Tap 2 within the double-tap window + fireEvent.touchStart(el, { touches: [touch(50, 50)] }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onDoubleTap).toHaveBeenCalledTimes(1); + }); + + it('fires onLongPress after the long-press delay', () => { + jest.useFakeTimers(); + const handlers = { onLongPress: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { touches: [touch(50, 50)] }); + }); + expect(handlers.onLongPress).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(500); + }); + expect(handlers.onLongPress).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.touchEnd(el, { touches: [] }); + }); + jest.useRealTimers(); + }); + + it('fires onPinch with the scale ratio for two-finger gestures', () => { + const handlers = { onPinch: jest.fn() }; + render(); + const el = screen.getByTestId('gesture-area'); + + act(() => { + fireEvent.touchStart(el, { + touches: [touch(0, 0), touch(100, 0)], + }); + fireEvent.touchMove(el, { + touches: [touch(0, 0), touch(200, 0)], + }); + fireEvent.touchEnd(el, { touches: [] }); + }); + + expect(handlers.onPinch).toHaveBeenCalledWith(2); + }); + + it('removes touch listeners on unmount', () => { + const handlers = { onSwipeLeft: jest.fn() }; + const { unmount } = render(); + const el = screen.getByTestId('gesture-area'); + + const removeSpy = jest.spyOn(el, 'removeEventListener'); + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('touchstart', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('touchend', expect.any(Function)); + }); +}); diff --git a/src/hooks/__tests__/usePaginationParams.test.ts b/src/hooks/__tests__/usePaginationParams.test.ts new file mode 100644 index 00000000..be60be29 --- /dev/null +++ b/src/hooks/__tests__/usePaginationParams.test.ts @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react'; +import { usePaginationParams, isValidPageSize, PAGE_SIZE_OPTIONS } from '../usePaginationParams'; + +const mockRouterPush = jest.fn(); +let mockSearchParams: URLSearchParams = new URLSearchParams(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: (...args: unknown[]) => mockRouterPush(...args) }), + useSearchParams: () => mockSearchParams, + usePathname: () => '/properties', +})); + +describe('isValidPageSize', () => { + it('accepts the configured page sizes', () => { + expect(PAGE_SIZE_OPTIONS).toEqual([12, 24, 48]); + for (const size of PAGE_SIZE_OPTIONS) { + expect(isValidPageSize(size)).toBe(true); + } + }); + + it('rejects arbitrary sizes', () => { + expect(isValidPageSize(10)).toBe(false); + expect(isValidPageSize(99)).toBe(false); + expect(isValidPageSize(0)).toBe(false); + }); +}); + +describe('usePaginationParams', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchParams = new URLSearchParams(); + window.scrollTo = jest.fn(); + }); + + it('defaults to page 1 and size 12 when no params are present', () => { + const { result } = renderHook(() => usePaginationParams()); + expect(result.current.page).toBe(1); + expect(result.current.size).toBe(12); + }); + + it('parses valid page and size params', () => { + mockSearchParams = new URLSearchParams('page=3&size=24'); + const { result } = renderHook(() => usePaginationParams()); + expect(result.current.page).toBe(3); + expect(result.current.size).toBe(24); + }); + + it('clamps invalid page values to 1', () => { + for (const value of ['0', '-2', 'abc', '2.5']) { + mockSearchParams = new URLSearchParams(`page=${value}`); + const { result } = renderHook(() => usePaginationParams()); + expect(result.current.page).toBe(1); + } + }); + + it('falls back to size 12 for invalid size values', () => { + mockSearchParams = new URLSearchParams('size=99'); + const { result } = renderHook(() => usePaginationParams()); + expect(result.current.size).toBe(12); + }); + + it('builds an href that preserves other query params', () => { + mockSearchParams = new URLSearchParams('sort=price-desc&page=1'); + const { result } = renderHook(() => usePaginationParams()); + + const href = result.current.buildHref(2, 24); + expect(href).toBe('/properties?sort=price-desc&page=2&size=24'); + }); + + it('buildHref defaults to the current size when omitted', () => { + mockSearchParams = new URLSearchParams('size=48'); + const { result } = renderHook(() => usePaginationParams()); + + expect(result.current.buildHref(4)).toBe('/properties?size=48&page=4&size=48'); + }); + + it('setPage pushes the updated href without scrolling', () => { + mockSearchParams = new URLSearchParams('sort=price-desc'); + const { result } = renderHook(() => usePaginationParams()); + + act(() => { + result.current.setPage(5); + }); + + expect(mockRouterPush).toHaveBeenCalledWith( + '/properties?sort=price-desc&page=5&size=12', + { scroll: false }, + ); + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); + + it('setSize resets to page 1 with the new size', () => { + mockSearchParams = new URLSearchParams('page=4'); + const { result } = renderHook(() => usePaginationParams()); + + act(() => { + result.current.setSize(48); + }); + + expect(mockRouterPush).toHaveBeenCalledWith( + '/properties?page=1&size=48', + { scroll: false }, + ); + }); +}); diff --git a/src/hooks/__tests__/usePaginationUrl.test.ts b/src/hooks/__tests__/usePaginationUrl.test.ts new file mode 100644 index 00000000..bac9520c --- /dev/null +++ b/src/hooks/__tests__/usePaginationUrl.test.ts @@ -0,0 +1,46 @@ +import { act, renderHook } from '@testing-library/react'; +import { usePaginationUrl } from '../usePaginationUrl'; + +const mockSetSearchParams = jest.fn(); +let mockSearchParams: URLSearchParams = new URLSearchParams(); + +jest.mock('react-router-dom', () => ({ + useSearchParams: () => [mockSearchParams, (...args: unknown[]) => mockSetSearchParams(...args)], +})); + +describe('usePaginationUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchParams = new URLSearchParams(); + }); + + it('defaults to page 1 when no page param is present', () => { + const { result } = renderHook(() => usePaginationUrl()); + expect(result.current.page).toBe(1); + }); + + it('parses the page param from the URL', () => { + mockSearchParams = new URLSearchParams('page=3'); + const { result } = renderHook(() => usePaginationUrl()); + expect(result.current.page).toBe(3); + }); + + it('falls back to page 1 for a non-numeric page param', () => { + mockSearchParams = new URLSearchParams('page=abc'); + const { result } = renderHook(() => usePaginationUrl()); + expect(result.current.page).toBe(1); + }); + + it('setPage updates the param and persists the URL', () => { + mockSearchParams = new URLSearchParams('page=1&sort=price'); + const { result } = renderHook(() => usePaginationUrl()); + + act(() => { + result.current.setPage(4); + }); + + expect(mockSearchParams.get('page')).toBe('4'); + expect(mockSearchParams.get('sort')).toBe('price'); // other params preserved + expect(mockSetSearchParams).toHaveBeenCalledWith(mockSearchParams); + }); +}); diff --git a/src/store/__tests__/portfolioStore.test.ts b/src/store/__tests__/portfolioStore.test.ts new file mode 100644 index 00000000..e1d97327 --- /dev/null +++ b/src/store/__tests__/portfolioStore.test.ts @@ -0,0 +1,214 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { usePortfolioStore } from '../portfolioStore'; +import type { MultiChainPortfolio, BridgeSuggestion } from '@/types/portfolio'; + +let mockWalletAddress: string | null = '0x1234...5678'; + +const mockFetchMultiChainPortfolio = jest.fn(); +const mockCalculateBridgeSuggestions = jest.fn(); + +jest.mock('@/lib/portfolioService', () => ({ + PortfolioService: { + fetchMultiChainPortfolio: (...args: unknown[]) => + mockFetchMultiChainPortfolio(...args), + calculateBridgeSuggestions: (...args: unknown[]) => + mockCalculateBridgeSuggestions(...args), + }, +})); + +jest.mock('../walletStore', () => ({ + useWalletStore: { + getState: () => ({ address: mockWalletAddress }), + }, +})); + +const mockPortfolio = (overrides: Partial = {}): MultiChainPortfolio => ({ + totalValueUSD: 500000, + totalValueNative: new Map(), + chains: [ + { + chainId: 1, + chainName: 'Ethereum', + chainSymbol: 'ETH', + chainColor: '#627EEA', + totalValueUSD: 400000, + totalValueNative: 150, + gasBalance: '2.45', + gasBalanceUSD: 6500, + holdings: [ + { + propertyId: 'prop-1', + propertyName: 'Manhattan Apt', + propertyImage: '/p.jpg', + tokenSymbol: 'MLA', + quantity: 150, + valueUSD: 225000, + valueNative: 85.5, + chainId: 1, + contractAddress: '0x1234', + acquisitionDate: '2024-01-15', + apy: 8.5, + }, + ], + }, + ], + lastUpdated: '2024-01-01T00:00:00Z', + isLoading: false, + error: null, + ...overrides, +}); + +const mockSuggestion: BridgeSuggestion = { + fromChain: 137, + toChain: 1, + propertyId: 'prop-2', + propertyName: 'Miami Condo', + currentValue: 180000, + potentialSavings: 3600, + reason: 'Consolidate small holdings', +}; + +describe('portfolioStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockWalletAddress = '0x1234...5678'; + usePortfolioStore.getState().clearPortfolio(); + }); + + it('starts with no portfolio and default filters', () => { + const { result } = renderHook(() => usePortfolioStore()); + expect(result.current.portfolio).toBeNull(); + expect(result.current.selectedChain).toBe('all'); + expect(result.current.bridgeSuggestions).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.lastRefreshed).toBeNull(); + }); + + it('loads the portfolio and derives bridge suggestions', async () => { + const portfolio = mockPortfolio(); + mockFetchMultiChainPortfolio.mockResolvedValue(portfolio); + mockCalculateBridgeSuggestions.mockReturnValue([mockSuggestion]); + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.loadPortfolio('0x1234...5678'); + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockFetchMultiChainPortfolio).toHaveBeenCalledWith('0x1234...5678'); + expect(mockCalculateBridgeSuggestions).toHaveBeenCalledWith(portfolio); + expect(result.current.portfolio?.totalValueUSD).toBe(500000); + expect(result.current.bridgeSuggestions).toEqual([mockSuggestion]); + expect(result.current.lastRefreshed).not.toBeNull(); + }); + + it('surfaces a portfolio-level error without throwing', async () => { + mockFetchMultiChainPortfolio.mockResolvedValue( + mockPortfolio({ error: 'Chain unavailable' }), + ); + mockCalculateBridgeSuggestions.mockReturnValue([]); + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.loadPortfolio('0x1234...5678'); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.portfolio).not.toBeNull(); + expect(result.current.error).toBe('Chain unavailable'); + }); + + it('handles a failed portfolio load', async () => { + mockFetchMultiChainPortfolio.mockRejectedValue(new Error('RPC timeout')); + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.loadPortfolio('0x1234...5678'); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.portfolio).toBeNull(); + expect(result.current.error).toBe('RPC timeout'); + }); + + it('refreshPortfolio errors when no wallet is connected', async () => { + mockWalletAddress = null; + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.refreshPortfolio(); + }); + + expect(result.current.error).toBe('No wallet connected'); + expect(mockFetchMultiChainPortfolio).not.toHaveBeenCalled(); + }); + + it('refreshPortfolio delegates to loadPortfolio with the connected address', async () => { + mockFetchMultiChainPortfolio.mockResolvedValue(mockPortfolio()); + mockCalculateBridgeSuggestions.mockReturnValue([]); + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.refreshPortfolio(); + }); + + await waitFor(() => expect(result.current.portfolio).not.toBeNull()); + expect(mockFetchMultiChainPortfolio).toHaveBeenCalledWith('0x1234...5678'); + }); + + it('sets the selected chain filter', () => { + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.setSelectedChain(137); + }); + + expect(result.current.selectedChain).toBe(137); + }); + + it('clearPortfolio resets portfolio state', () => { + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.setSelectedChain(137); + result.current.clearPortfolio(); + }); + + expect(result.current.portfolio).toBeNull(); + expect(result.current.bridgeSuggestions).toEqual([]); + expect(result.current.selectedChain).toBe('all'); + expect(result.current.error).toBeNull(); + expect(result.current.lastRefreshed).toBeNull(); + }); + + it('calculateBridgeSuggestions returns [] without a portfolio', () => { + const { result } = renderHook(() => usePortfolioStore()); + expect(result.current.calculateBridgeSuggestions()).toEqual([]); + }); + + it('calculateBridgeSuggestions delegates to the service when loaded', () => { + mockFetchMultiChainPortfolio.mockResolvedValue(mockPortfolio()); + mockCalculateBridgeSuggestions.mockReturnValue([mockSuggestion]); + + const { result } = renderHook(() => usePortfolioStore()); + + act(() => { + result.current.loadPortfolio('0x1234...5678'); + }); + + act(() => { + expect(result.current.calculateBridgeSuggestions()).toEqual([mockSuggestion]); + }); + }); +});