diff --git a/src/hooks/__tests__/useDebouncedSearch.test.ts b/src/hooks/__tests__/useDebouncedSearch.test.ts new file mode 100644 index 00000000..48d98ad3 --- /dev/null +++ b/src/hooks/__tests__/useDebouncedSearch.test.ts @@ -0,0 +1,157 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedSearch } from '../useDebouncedSearch'; + +describe('useDebouncedSearch', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts with an empty query and no results', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + expect(result.current.query).toBe(''); + expect(result.current.results).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('does not fire the search until the debounce window elapses', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + act(() => { + result.current.setQuery('hello'); + }); + + expect(searchFn).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(searchFn).toHaveBeenCalledTimes(1); + expect(searchFn).toHaveBeenCalledWith('hello', expect.any(AbortSignal)); + }); + + it('collapses rapid input into a single search with the latest query', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + act(() => { + result.current.setQuery('h'); + jest.advanceTimersByTime(100); + result.current.setQuery('he'); + jest.advanceTimersByTime(100); + result.current.setQuery('hel'); + jest.advanceTimersByTime(100); + result.current.setQuery('hello'); + jest.advanceTimersByTime(300); + }); + + expect(searchFn).toHaveBeenCalledTimes(1); + expect(searchFn).toHaveBeenCalledWith('hello', expect.any(AbortSignal)); + }); + + it('sets results once the async search resolves', async () => { + const searchFn = jest.fn().mockResolvedValue(['result-1']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + act(() => { + result.current.setQuery('hello'); + }); + + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.results).toEqual(['result-1']); + expect(result.current.isLoading).toBe(false); + }); + + it('surfaces errors from the search function', async () => { + const searchFn = jest.fn().mockRejectedValue(new Error('Network error')); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + act(() => { + result.current.setQuery('hello'); + }); + + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.error).toEqual(new Error('Network error')); + expect(result.current.isLoading).toBe(false); + }); + + it('respects minLength and does not search short queries', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300, minLength: 3, initialResults: ['init'] }), + ); + + act(() => { + result.current.setQuery('hi'); + jest.advanceTimersByTime(300); + }); + + expect(searchFn).not.toHaveBeenCalled(); + expect(result.current.results).toEqual(['init']); + expect(result.current.isLoading).toBe(false); + }); + + it('clear resets query, results and error', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300, initialResults: ['init'] }), + ); + + act(() => { + result.current.setQuery('hello'); + result.current.clear(); + }); + + expect(result.current.query).toBe(''); + expect(result.current.results).toEqual(['init']); + expect(result.current.error).toBeNull(); + }); + + it('cleans up pending timers on unmount', () => { + const searchFn = jest.fn().mockResolvedValue(['a']); + const { result, unmount } = renderHook(() => + useDebouncedSearch({ searchFn, delay: 300 }), + ); + + act(() => { + result.current.setQuery('hello'); + }); + + unmount(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + // The pending search must not fire after unmount. + expect(searchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/__tests__/certificateStore.test.ts b/src/store/__tests__/certificateStore.test.ts new file mode 100644 index 00000000..74b58d8e --- /dev/null +++ b/src/store/__tests__/certificateStore.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCertificateStore } from '../certificateStore'; +import type { NFTCertificate } from '@/types/certificate'; + +const mockCertificate = (overrides: Partial = {}): NFTCertificate => ({ + id: 'cert-1', + propertyId: 'prop-1', + propertyName: 'Test Property', + propertyAddress: '1 Test St', + propertyImage: null, + tokenAmount: 10, + tokenSymbol: 'TST', + walletAddress: '0xAbCd...W1', + purchaseDate: '2024-01-15T10:00:00Z', + transactionHash: '0xtxhash123', + network: 'ethereum', + contractAddress: '0x1234...5678', + ownershipPercentage: 1, + ...overrides, +}); + +describe('certificateStore', () => { + beforeEach(() => { + localStorage.clear(); + useCertificateStore.setState({ certificates: [] }); + }); + + it('starts with no certificates', () => { + const { result } = renderHook(() => useCertificateStore()); + expect(result.current.certificates).toEqual([]); + expect( + result.current.getCertificate('prop-1', '0xAbCd...W1'), + ).toBeUndefined(); + }); + + it('adds a certificate', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate()); + }); + + expect(result.current.certificates).toHaveLength(1); + expect(result.current.certificates[0].id).toBe('cert-1'); + }); + + it('replaces a certificate for the same property and wallet', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate({ id: 'cert-1', tokenAmount: 10 })); + result.current.addCertificate(mockCertificate({ id: 'cert-2', tokenAmount: 25 })); + }); + + expect(result.current.certificates).toHaveLength(1); + expect(result.current.certificates[0].id).toBe('cert-2'); + expect(result.current.certificates[0].tokenAmount).toBe(25); + }); + + it('keeps separate certificates for different wallets', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate({ walletAddress: '0xAbCd...W1' })); + result.current.addCertificate(mockCertificate({ walletAddress: '0xEfGh...W2' })); + }); + + expect(result.current.certificates).toHaveLength(2); + }); + + it('keeps separate certificates for different properties', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate({ propertyId: 'prop-1' })); + result.current.addCertificate(mockCertificate({ propertyId: 'prop-2' })); + }); + + expect(result.current.certificates).toHaveLength(2); + }); + + it('getCertificate returns the matching certificate', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate( + mockCertificate({ propertyId: 'prop-1', walletAddress: '0xAbCd...W1' }), + ); + result.current.addCertificate( + mockCertificate({ propertyId: 'prop-2', walletAddress: '0xAbCd...W1' }), + ); + }); + + const found = result.current.getCertificate('prop-2', '0xAbCd...W1'); + expect(found?.propertyId).toBe('prop-2'); + }); + + it('getCertificate returns undefined when nothing matches', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate()); + }); + + expect(result.current.getCertificate('prop-9', '0xAbCd...W1')).toBeUndefined(); + expect(result.current.getCertificate('prop-1', '0xOther...W9')).toBeUndefined(); + }); + + it('persists certificates across store instances', () => { + const { result } = renderHook(() => useCertificateStore()); + + act(() => { + result.current.addCertificate(mockCertificate()); + }); + + const { result: result2 } = renderHook(() => useCertificateStore()); + expect(result2.current.certificates).toHaveLength(1); + expect(result2.current.certificates[0].id).toBe('cert-1'); + }); +}); diff --git a/src/store/__tests__/favoritesStore.test.ts b/src/store/__tests__/favoritesStore.test.ts new file mode 100644 index 00000000..8374b578 --- /dev/null +++ b/src/store/__tests__/favoritesStore.test.ts @@ -0,0 +1,133 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFavoritesStore } from '../favoritesStore'; +import type { Property } from '@/types/property'; + +const mockProperty = (id: string): Property => ({ + id, + name: `Property ${id}`, + description: 'A test property', + location: { + address: '1 Test St', + city: 'Testville', + state: 'TS', + country: 'Testland', + zipCode: '00000', + coordinates: { lat: 0, lng: 0 }, + }, + price: { total: 100000, perToken: 100, currency: 'USD' }, + propertyType: 'residential', + blockchain: 'ethereum', + tokenInfo: { + totalSupply: 1000, + available: 900, + sold: 100, + contractAddress: '0x1234...5678', + tokenSymbol: 'TST', + }, + metrics: { + roi: 8, + annualReturn: 8000, + transactionVolume: 50000, + appreciationRate: 5, + }, + details: { squareFeet: 1000, yearBuilt: 2020, amenities: [] }, + images: [], + listedDate: '2024-01-01', + status: 'active', +}); + +describe('favoritesStore', () => { + beforeEach(() => { + localStorage.clear(); + useFavoritesStore.getState().clearFavorites(); + }); + + it('starts with an empty favorites list', () => { + const { result } = renderHook(() => useFavoritesStore()); + expect(result.current.favorites).toEqual([]); + expect(result.current.getFavoritesCount()).toBe(0); + }); + + it('adds a favorite property', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + }); + + expect(result.current.favorites).toHaveLength(1); + expect(result.current.favorites[0].id).toBe('prop-1'); + expect(result.current.isFavorite('prop-1')).toBe(true); + expect(result.current.getFavoritesCount()).toBe(1); + }); + + it('adds multiple favorites without deduplicating (toggle is explicit)', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + result.current.addFavorite(mockProperty('prop-2')); + }); + + expect(result.current.getFavoritesCount()).toBe(2); + }); + + it('removes a favorite property', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + result.current.addFavorite(mockProperty('prop-2')); + result.current.removeFavorite('prop-1'); + }); + + expect(result.current.favorites).toHaveLength(1); + expect(result.current.favorites[0].id).toBe('prop-2'); + expect(result.current.isFavorite('prop-1')).toBe(false); + }); + + it('isFavorite returns false for unknown ids', () => { + const { result } = renderHook(() => useFavoritesStore()); + expect(result.current.isFavorite('does-not-exist')).toBe(false); + }); + + it('clearFavorites empties the list', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + result.current.clearFavorites(); + }); + + expect(result.current.favorites).toEqual([]); + expect(result.current.getFavoritesCount()).toBe(0); + }); + + it('persists favorites across store instances', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + }); + + // A fresh hook instance hydrates from localStorage. + const { result: result2 } = renderHook(() => useFavoritesStore()); + expect(result2.current.getFavoritesCount()).toBe(1); + expect(result2.current.isFavorite('prop-1')).toBe(true); + }); + + it('persists removal across store instances', () => { + const { result } = renderHook(() => useFavoritesStore()); + + act(() => { + result.current.addFavorite(mockProperty('prop-1')); + result.current.addFavorite(mockProperty('prop-2')); + result.current.removeFavorite('prop-1'); + }); + + const { result: result2 } = renderHook(() => useFavoritesStore()); + expect(result2.current.getFavoritesCount()).toBe(1); + expect(result2.current.isFavorite('prop-2')).toBe(true); + expect(result2.current.isFavorite('prop-1')).toBe(false); + }); +}); diff --git a/src/store/__tests__/walletStore.test.ts b/src/store/__tests__/walletStore.test.ts new file mode 100644 index 00000000..07e2ed27 --- /dev/null +++ b/src/store/__tests__/walletStore.test.ts @@ -0,0 +1,159 @@ +import { act, renderHook } from '@testing-library/react'; +import { useWalletStore } from '../walletStore'; +import { DEFAULT_CHAIN_ID } from '@/config/chains'; + +describe('walletStore', () => { + beforeEach(() => { + useWalletStore.getState().reset(); + }); + + it('has the correct initial state', () => { + const { result } = renderHook(() => useWalletStore()); + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.walletType).toBeNull(); + expect(result.current.chainId).toBe(DEFAULT_CHAIN_ID); + expect(result.current.isConnecting).toBe(false); + expect(result.current.isSwitchingNetwork).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.lastUpdated).toBeNull(); + }); + + it('connects a wallet with address, type and chain', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnected('0x1234...5678', 'metamask', 137); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.address).toBe('0x1234...5678'); + expect(result.current.walletType).toBe('metamask'); + expect(result.current.chainId).toBe(137); + expect(result.current.isConnecting).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.lastUpdated).toBeGreaterThan(0); + }); + + it('defaults the chain to the default chain id when connecting', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnected('0x1234...5678', 'walletconnect'); + }); + + expect(result.current.chainId).toBe(DEFAULT_CHAIN_ID); + }); + + it('switches the network chain id', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnected('0x1234...5678', 'metamask'); + result.current.setChainId(56); + }); + + expect(result.current.chainId).toBe(56); + expect(result.current.isSwitchingNetwork).toBe(false); + }); + + it('disconnects and fully resets connection state', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnected('0x1234...5678', 'coinbase', 137); + result.current.setBalance('2.5'); + result.current.setLoading(true); + result.current.setDisconnected(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.walletType).toBeNull(); + expect(result.current.chainId).toBe(DEFAULT_CHAIN_ID); + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.lastUpdated).toBeNull(); + }); + + it('tracks connecting state', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnecting(true); + }); + expect(result.current.isConnecting).toBe(true); + + act(() => { + result.current.setConnecting(false); + }); + expect(result.current.isConnecting).toBe(false); + }); + + it('tracks switching network state', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setSwitchingNetwork(true); + }); + expect(result.current.isSwitchingNetwork).toBe(true); + }); + + it('sets and clears errors', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setError('User rejected request'); + }); + expect(result.current.error).toBe('User rejected request'); + expect(result.current.isConnecting).toBe(false); + expect(result.current.isSwitchingNetwork).toBe(false); + + act(() => { + result.current.clearError(); + }); + expect(result.current.error).toBeNull(); + }); + + it('sets the balance', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setBalance('3.75'); + }); + + expect(result.current.balance).toBe('3.75'); + expect(result.current.lastUpdated).toBeGreaterThan(0); + }); + + it('tracks loading and last updated', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setLoading(true); + result.current.setLastUpdated(12345); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.lastUpdated).toBe(12345); + }); + + it('resets to the initial state', () => { + const { result } = renderHook(() => useWalletStore()); + + act(() => { + result.current.setConnected('0x1234...5678', 'metamask'); + result.current.setError('boom'); + result.current.reset(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.walletType).toBeNull(); + expect(result.current.chainId).toBe(DEFAULT_CHAIN_ID); + expect(result.current.error).toBeNull(); + expect(result.current.balance).toBeNull(); + }); +});