diff --git a/src/store/__tests__/developerStore.test.ts b/src/store/__tests__/developerStore.test.ts new file mode 100644 index 00000000..53fa963a --- /dev/null +++ b/src/store/__tests__/developerStore.test.ts @@ -0,0 +1,126 @@ +import { act, renderHook } from '@testing-library/react'; +import { useDeveloperStore } from '../developerStore'; +import type { DeveloperProfile } from '@/types/developer'; + +const mockDeveloper = (overrides: Partial = {}): DeveloperProfile => ({ + id: 'dev-new', + walletAddress: '0xNewWallet1234567890NewWallet1234567890NewWallet12', + name: 'New Dev Co', + company: 'New Dev Co LLC', + description: 'A fresh developer', + propertyIds: ['prop-9'], + verificationStatus: 'pending', + documents: [], + createdAt: '2024-03-01T00:00:00Z', + ...overrides, +}); + +describe('developerStore', () => { + beforeEach(() => { + localStorage.clear(); + // Restore the seeded mock developers. + useDeveloperStore.setState({ + developers: [ + mockDeveloper({ + id: 'dev-1', + walletAddress: '0xAbCd1234567890AbCd1234567890AbCd12345678', + name: 'Skyline Properties', + propertyIds: ['prop-1', 'prop-2'], + verificationStatus: 'verified', + verifiedAt: '2024-01-15T10:00:00Z', + }), + mockDeveloper({ + id: 'dev-2', + walletAddress: '0xDeF0987654321DeF0987654321DeF098765432', + name: 'Urban Ventures', + propertyIds: ['prop-3'], + verificationStatus: 'pending', + }), + ], + }); + }); + + it('is seeded with mock verified developers', () => { + const { result } = renderHook(() => useDeveloperStore()); + expect(result.current.developers).toHaveLength(2); + expect(result.current.developers[0].name).toBe('Skyline Properties'); + }); + + it('upserts a new developer', () => { + const { result } = renderHook(() => useDeveloperStore()); + + act(() => { + result.current.upsertDeveloper(mockDeveloper()); + }); + + expect(result.current.developers).toHaveLength(3); + expect(result.current.developers[0].id).toBe('dev-new'); + }); + + it('upserting an existing developer updates the profile in place', () => { + const { result } = renderHook(() => useDeveloperStore()); + + act(() => { + result.current.upsertDeveloper( + mockDeveloper({ id: 'dev-1', name: 'Skyline Rebranded' }), + ); + }); + + expect(result.current.developers).toHaveLength(2); + const dev1 = result.current.developers.find((d) => d.id === 'dev-1'); + expect(dev1?.name).toBe('Skyline Rebranded'); + }); + + it('looks up a developer by wallet address case-insensitively', () => { + const { result } = renderHook(() => useDeveloperStore()); + + const found = result.current.getDeveloperByWallet( + '0xabcd1234567890abcd1234567890abcd12345678', + ); + expect(found?.name).toBe('Skyline Properties'); + expect(result.current.getDeveloperByWallet('0xNope...')).toBeUndefined(); + }); + + it('looks up a developer by property id', () => { + const { result } = renderHook(() => useDeveloperStore()); + + expect(result.current.getDeveloperByProperty('prop-3')?.name).toBe('Urban Ventures'); + expect(result.current.getDeveloperByProperty('prop-9')).toBeUndefined(); + }); + + it('marks a developer as verified with a verifiedAt timestamp', () => { + const { result } = renderHook(() => useDeveloperStore()); + + act(() => { + result.current.updateVerificationStatus('dev-2', 'verified'); + }); + + const dev2 = result.current.developers.find((d) => d.id === 'dev-2'); + expect(dev2?.verificationStatus).toBe('verified'); + expect(dev2?.verifiedAt).toBeTruthy(); + }); + + it('records the rejection reason when rejecting a developer', () => { + const { result } = renderHook(() => useDeveloperStore()); + + act(() => { + result.current.updateVerificationStatus('dev-2', 'rejected', 'Documents incomplete'); + }); + + const dev2 = result.current.developers.find((d) => d.id === 'dev-2'); + expect(dev2?.verificationStatus).toBe('rejected'); + expect(dev2?.rejectionReason).toBe('Documents incomplete'); + }); + + it('persists developer state across store instances', () => { + const { result } = renderHook(() => useDeveloperStore()); + + act(() => { + result.current.upsertDeveloper(mockDeveloper()); + }); + + const { result: result2 } = renderHook(() => useDeveloperStore()); + expect(result2.current.developers).toHaveLength(3); + expect(result2.current.developers[0].id).toBe('dev-new'); + }); +}); diff --git a/src/store/__tests__/gasPriceStore.test.ts b/src/store/__tests__/gasPriceStore.test.ts new file mode 100644 index 00000000..dbb44aff --- /dev/null +++ b/src/store/__tests__/gasPriceStore.test.ts @@ -0,0 +1,59 @@ +import { act, renderHook } from '@testing-library/react'; +import { useGasPriceStore } from '../gasPriceStore'; + +describe('gasPriceStore', () => { + beforeEach(() => { + localStorage.clear(); + useGasPriceStore.setState({ gasPrice: null, gasPriceThreshold: 20 }); + }); + + it('starts with no cached price and the default threshold', () => { + const { result } = renderHook(() => useGasPriceStore()); + expect(result.current.gasPrice).toBeNull(); + expect(result.current.gasPriceThreshold).toBe(20); + }); + + it('sets the current gas price', () => { + const { result } = renderHook(() => useGasPriceStore()); + + act(() => { + result.current.setGasPrice(35); + }); + + expect(result.current.gasPrice).toBe(35); + }); + + it('updates an existing gas price', () => { + const { result } = renderHook(() => useGasPriceStore()); + + act(() => { + result.current.setGasPrice(35); + result.current.setGasPrice(42); + }); + + expect(result.current.gasPrice).toBe(42); + }); + + it('sets the gas price threshold', () => { + const { result } = renderHook(() => useGasPriceStore()); + + act(() => { + result.current.setGasPriceThreshold(50); + }); + + expect(result.current.gasPriceThreshold).toBe(50); + }); + + it('persists the cached price across store instances', () => { + const { result } = renderHook(() => useGasPriceStore()); + + act(() => { + result.current.setGasPrice(60); + result.current.setGasPriceThreshold(55); + }); + + const { result: result2 } = renderHook(() => useGasPriceStore()); + expect(result2.current.gasPrice).toBe(60); + expect(result2.current.gasPriceThreshold).toBe(55); + }); +}); diff --git a/src/store/__tests__/notificationStore.test.ts b/src/store/__tests__/notificationStore.test.ts new file mode 100644 index 00000000..908e2a6e --- /dev/null +++ b/src/store/__tests__/notificationStore.test.ts @@ -0,0 +1,241 @@ +import { act, renderHook } from '@testing-library/react'; +import { useNotificationStore } from '../notificationStore'; +import type { PropertyAlert, PriceAlert, PriceAlertNotification } from '@/types/property'; + +const mockAlert = (overrides: Partial = {}): PropertyAlert => ({ + id: 'alert-1', + savedSearchId: 'ss-1', + savedSearchName: 'My search', + matchingProperties: [], + newPropertiesCount: 3, + createdAt: '2024-01-01T00:00:00Z', + isRead: false, + userId: '0xuser', + ...overrides, +}); + +const mockPriceAlert = (overrides: Partial = {}): PriceAlert => ({ + id: 'pa-1', + propertyId: 'prop-1', + propertyName: 'Manhattan Apt', + alertType: 'above', + targetPrice: 500000, + currentPrice: 480000, + createdAt: '2024-01-01T00:00:00Z', + isActive: true, + isTriggered: false, + userId: '0xuser', + emailNotification: false, + ...overrides, +}); + +const mockPriceAlertNotification = ( + overrides: Partial = {}, +): PriceAlertNotification => ({ + id: 'notif-1', + alertId: 'pa-1', + propertyId: 'prop-1', + propertyName: 'Manhattan Apt', + alertType: 'above', + targetPrice: 500000, + triggeredPrice: 510000, + message: 'Manhattan Apt price has risen above $500,000. Current price: $510,000', + createdAt: '2024-01-01T00:00:00Z', + isRead: false, + userId: '0xuser', + ...overrides, +}); + +const unreadCount = (alerts: PropertyAlert[]) => alerts.filter((a) => !a.isRead).length; + +describe('notificationStore', () => { + beforeEach(() => { + localStorage.clear(); + useNotificationStore.getState().reset(); + }); + + it('starts with no alerts and default settings', () => { + const { result } = renderHook(() => useNotificationStore()); + expect(result.current.alerts).toEqual([]); + expect(result.current.priceAlerts).toEqual([]); + expect(result.current.settings.inAppEnabled).toBe(true); + expect(result.current.settings.defaultFrequency).toBe('daily'); + }); + + it('adds an alert and derives the unread count', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addAlert(mockAlert({ id: 'alert-2', isRead: true })); + }); + + expect(result.current.alerts).toHaveLength(2); + expect(unreadCount(result.current.alerts)).toBe(1); + }); + + it('does not duplicate an alert with the same id', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addAlert(mockAlert()); + }); + + expect(result.current.alerts).toHaveLength(1); + }); + + it('marks a single alert as read', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addAlert(mockAlert({ id: 'alert-2' })); + result.current.markAsRead('alert-1'); + }); + + expect(result.current.alerts[0].isRead).toBe(true); + expect(unreadCount(result.current.alerts)).toBe(1); + }); + + it('marks all alerts as read', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addAlert(mockAlert({ id: 'alert-2' })); + result.current.markAllAsRead(); + }); + + expect(unreadCount(result.current.alerts)).toBe(0); + expect(result.current.alerts.every((a) => a.isRead)).toBe(true); + }); + + it('clears a single alert and all alerts', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addAlert(mockAlert({ id: 'alert-2' })); + result.current.clearAlert('alert-1'); + }); + + expect(result.current.alerts).toHaveLength(1); + + act(() => { + result.current.clearAllAlerts(); + }); + + expect(result.current.alerts).toEqual([]); + }); + + it('adds a new price alert', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addPriceAlert(mockPriceAlert()); + }); + + expect(result.current.priceAlerts).toHaveLength(1); + expect(result.current.priceAlerts[0].id).toBe('pa-1'); + }); + + it('updates an existing price alert for the same property and type', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addPriceAlert(mockPriceAlert()); + result.current.addPriceAlert(mockPriceAlert({ id: 'pa-2', targetPrice: 600000 })); + }); + + expect(result.current.priceAlerts).toHaveLength(1); + expect(result.current.priceAlerts[0].id).toBe('pa-2'); + expect(result.current.priceAlerts[0].targetPrice).toBe(600000); + }); + + it('updates, removes and toggles price alerts', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addPriceAlert(mockPriceAlert()); + result.current.updatePriceAlert('pa-1', { targetPrice: 550000 }); + }); + + expect(result.current.priceAlerts[0].targetPrice).toBe(550000); + + act(() => { + result.current.togglePriceAlert('pa-1'); + }); + expect(result.current.priceAlerts[0].isActive).toBe(false); + + act(() => { + result.current.removePriceAlert('pa-1'); + }); + expect(result.current.priceAlerts).toEqual([]); + }); + + it('triggering a price alert records a notification and marks the alert triggered', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addPriceAlert(mockPriceAlert()); + result.current.triggerPriceAlert('pa-1', 520000); + }); + + expect(result.current.priceAlerts[0].isTriggered).toBe(true); + expect(result.current.priceAlertNotifications).toHaveLength(1); + expect(result.current.priceAlertNotifications[0].triggeredPrice).toBe(520000); + expect(result.current.priceAlertNotifications[0].message).toContain('risen above'); + }); + + it('manages price alert notification read and clear state', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addPriceAlertNotification(mockPriceAlertNotification()); + result.current.addPriceAlertNotification( + mockPriceAlertNotification({ id: 'notif-2' }), + ); + result.current.markPriceAlertNotificationAsRead('notif-1'); + }); + + expect(result.current.priceAlertNotifications[0].isRead).toBe(true); + + act(() => { + result.current.clearPriceAlertNotification('notif-1'); + }); + expect(result.current.priceAlertNotifications).toHaveLength(1); + + act(() => { + result.current.clearAllPriceAlertNotifications(); + }); + expect(result.current.priceAlertNotifications).toEqual([]); + }); + + it('updates notification settings', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.updateSettings({ emailEnabled: true, email: 'a@b.com' }); + }); + + expect(result.current.settings.emailEnabled).toBe(true); + expect(result.current.settings.email).toBe('a@b.com'); + expect(result.current.settings.inAppEnabled).toBe(true); // untouched + }); + + it('reset restores the default state', () => { + const { result } = renderHook(() => useNotificationStore()); + + act(() => { + result.current.addAlert(mockAlert()); + result.current.addPriceAlert(mockPriceAlert()); + result.current.updateSettings({ emailEnabled: true }); + result.current.reset(); + }); + + expect(result.current.alerts).toEqual([]); + expect(result.current.priceAlerts).toEqual([]); + expect(result.current.settings.emailEnabled).toBe(false); + }); +}); diff --git a/src/store/__tests__/onboardingStore.test.ts b/src/store/__tests__/onboardingStore.test.ts new file mode 100644 index 00000000..146472ae --- /dev/null +++ b/src/store/__tests__/onboardingStore.test.ts @@ -0,0 +1,131 @@ +import { act, renderHook } from '@testing-library/react'; +import { useOnboardingStore } from '../onboardingStore'; + +describe('onboardingStore', () => { + beforeEach(() => { + localStorage.clear(); + useOnboardingStore.getState().reset(); + }); + + it('starts in the inactive initial state', () => { + const { result } = renderHook(() => useOnboardingStore()); + expect(result.current.isActive).toBe(false); + expect(result.current.currentStep).toBe(0); + expect(result.current.hasCompletedOnboarding).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('starts onboarding from step zero', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.startOnboarding(); + }); + + expect(result.current.isActive).toBe(true); + expect(result.current.currentStep).toBe(0); + }); + + it('does not restart onboarding once completed', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.completeOnboarding(); + }); + + expect(result.current.hasCompletedOnboarding).toBe(true); + + act(() => { + result.current.startOnboarding(); + }); + + expect(result.current.isActive).toBe(false); + }); + + it('stops onboarding', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.startOnboarding(); + result.current.stopOnboarding(); + }); + + expect(result.current.isActive).toBe(false); + }); + + it('advances to the next step', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.nextStep(); + result.current.nextStep(); + }); + + expect(result.current.currentStep).toBe(2); + }); + + it('never goes below step zero when going back', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.prevStep(); + result.current.prevStep(); + }); + + expect(result.current.currentStep).toBe(0); + }); + + it('completes onboarding and resets the step', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.startOnboarding(); + result.current.nextStep(); + result.current.completeOnboarding(); + }); + + expect(result.current.isActive).toBe(false); + expect(result.current.hasCompletedOnboarding).toBe(true); + expect(result.current.currentStep).toBe(0); + }); + + it('resetOnboarding clears the completed flag', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.completeOnboarding(); + result.current.resetOnboarding(); + }); + + expect(result.current.hasCompletedOnboarding).toBe(false); + expect(result.current.currentStep).toBe(0); + }); + + it('reset restores the initial state', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.startOnboarding(); + result.current.nextStep(); + result.current.setError('boom'); + result.current.reset(); + }); + + expect(result.current.isActive).toBe(false); + expect(result.current.currentStep).toBe(0); + expect(result.current.hasCompletedOnboarding).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('persists completed progress across store instances', () => { + const { result } = renderHook(() => useOnboardingStore()); + + act(() => { + result.current.completeOnboarding(); + }); + + const { result: result2 } = renderHook(() => useOnboardingStore()); + expect(result2.current.hasCompletedOnboarding).toBe(true); + }); +});