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