287 lines
9.5 KiB
Plaintext
287 lines
9.5 KiB
Plaintext
// src/hooks/useProfileAddress.test.ts
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, type Mock, afterEach } from 'vitest';
|
|
import toast from 'react-hot-toast';
|
|
import { useProfileAddress } from './useProfileAddress';
|
|
import { useApi } from './useApi';
|
|
import { logger } from '../services/logger.client';
|
|
import { createMockAddress, createMockUserProfile } from '../tests/utils/mockFactories';
|
|
|
|
// Mock dependencies
|
|
vi.mock('react-hot-toast', () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('./useApi');
|
|
vi.mock('../services/logger.client', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockedUseApi = vi.mocked(useApi);
|
|
const mockedToast = vi.mocked(toast);
|
|
|
|
// Mock data
|
|
const mockUserProfile = createMockUserProfile({
|
|
address_id: 1,
|
|
full_name: 'Test User',
|
|
});
|
|
|
|
const mockUserProfileNoAddress = createMockUserProfile({
|
|
...mockUserProfile,
|
|
address_id: null,
|
|
});
|
|
|
|
const mockAddress = createMockAddress({
|
|
address_id: 1,
|
|
address_line_1: '123 Main St',
|
|
city: 'Anytown',
|
|
province_state: 'CA',
|
|
postal_code: '12345',
|
|
country: 'USA',
|
|
latitude: 34.05,
|
|
longitude: -118.25,
|
|
});
|
|
|
|
describe('useProfileAddress Hook', () => {
|
|
let mockGeocode: Mock;
|
|
let mockFetchAddress: Mock;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockGeocode = vi.fn();
|
|
mockFetchAddress = vi.fn();
|
|
|
|
// Robust mock implementation based on argument types.
|
|
// This handles the two useApi calls (Geocode: string input, Fetch: number input)
|
|
// without relying on unstable function names or render order.
|
|
mockedUseApi.mockImplementation(() => {
|
|
return {
|
|
execute: vi.fn(async (arg: any) => {
|
|
if (typeof arg === 'string') {
|
|
return mockGeocode(arg);
|
|
} else if (typeof arg === 'number') {
|
|
return mockFetchAddress(arg);
|
|
}
|
|
}),
|
|
loading: false,
|
|
error: null,
|
|
data: null,
|
|
reset: vi.fn(),
|
|
isRefetching: false,
|
|
};
|
|
});
|
|
});
|
|
|
|
it('should initialize with empty address and initialAddress', () => {
|
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
|
expect(result.current.address).toEqual({});
|
|
expect(result.current.initialAddress).toEqual({});
|
|
expect(result.current.isGeocoding).toBe(false);
|
|
});
|
|
|
|
describe('Address Fetching Effect', () => {
|
|
it('should not fetch address if isOpen is false', () => {
|
|
renderHook(() => useProfileAddress(mockUserProfile, false));
|
|
expect(mockFetchAddress).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not fetch address if profile is null', () => {
|
|
renderHook(() => useProfileAddress(null, true));
|
|
expect(mockFetchAddress).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fetch address when isOpen and a profile with address_id are provided', async () => {
|
|
mockFetchAddress.mockResolvedValue(mockAddress);
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
|
expect(result.current.address).toEqual(mockAddress);
|
|
expect(result.current.initialAddress).toEqual(mockAddress);
|
|
});
|
|
});
|
|
|
|
it('should reset address if profile has no address_id', () => {
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfileNoAddress, true));
|
|
expect(mockFetchAddress).not.toHaveBeenCalled();
|
|
expect(result.current.address).toEqual({});
|
|
expect(result.current.initialAddress).toEqual({});
|
|
});
|
|
|
|
it('should reset state when modal is closed', async () => {
|
|
mockFetchAddress.mockResolvedValue(mockAddress);
|
|
const { result, rerender } = renderHook(
|
|
({ userProfile, isOpen }) => useProfileAddress(userProfile, isOpen),
|
|
{ initialProps: { userProfile: mockUserProfile, isOpen: true } },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.address).toEqual(mockAddress);
|
|
});
|
|
|
|
rerender({ userProfile: mockUserProfile, isOpen: false });
|
|
|
|
expect(result.current.address).toEqual({});
|
|
expect(result.current.initialAddress).toEqual({});
|
|
});
|
|
|
|
it('should handle fetch failure gracefully', async () => {
|
|
mockFetchAddress.mockResolvedValue(null);
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id);
|
|
});
|
|
|
|
expect(result.current.address).toEqual({});
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null'));
|
|
});
|
|
});
|
|
|
|
describe('handleAddressChange', () => {
|
|
it('should update the address state field by field', () => {
|
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
|
|
|
act(() => {
|
|
result.current.handleAddressChange('city', 'New York');
|
|
});
|
|
expect(result.current.address.city).toBe('New York');
|
|
|
|
act(() => {
|
|
result.current.handleAddressChange('postal_code', '10001');
|
|
});
|
|
expect(result.current.address.city).toBe('New York');
|
|
expect(result.current.address.postal_code).toBe('10001');
|
|
});
|
|
});
|
|
|
|
describe('handleManualGeocode', () => {
|
|
it('should call geocode API with the correct address string', async () => {
|
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
|
|
|
act(() => {
|
|
result.current.handleAddressChange('address_line_1', '1 Infinite Loop');
|
|
result.current.handleAddressChange('city', 'Cupertino');
|
|
result.current.handleAddressChange('province_state', 'CA');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.handleManualGeocode();
|
|
});
|
|
|
|
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('1 Infinite Loop'));
|
|
});
|
|
|
|
it('should update address with new coordinates on successful geocode', async () => {
|
|
const newCoords = { lat: 37.33, lng: -122.03 };
|
|
mockGeocode.mockResolvedValue(newCoords);
|
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
|
|
|
act(() => {
|
|
result.current.handleAddressChange('city', 'Cupertino');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.handleManualGeocode();
|
|
});
|
|
|
|
expect(result.current.address.latitude).toBe(newCoords.lat);
|
|
expect(result.current.address.longitude).toBe(newCoords.lng);
|
|
expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
|
|
});
|
|
|
|
it('should show an error toast if address string is empty', async () => {
|
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
|
|
|
await act(async () => {
|
|
await result.current.handleManualGeocode();
|
|
});
|
|
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
expect(mockedToast.error).toHaveBeenCalledWith(
|
|
'Please fill in the address fields before geocoding.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Automatic Geocoding (Debounce)', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should trigger geocode after user stops typing in an address without coordinates', async () => {
|
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
|
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
|
const newCoords = { lat: 38.89, lng: -77.03 };
|
|
mockGeocode.mockResolvedValue(newCoords);
|
|
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
|
|
|
// Wait for initial fetch
|
|
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
|
|
|
// Change the address
|
|
act(() => {
|
|
result.current.handleAddressChange('city', 'Washington');
|
|
});
|
|
|
|
// Geocode should not be called immediately due to debounce
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
|
|
// Advance debounce timer
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1600);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington'));
|
|
expect(result.current.address.latitude).toBe(newCoords.lat);
|
|
expect(result.current.address.longitude).toBe(newCoords.lng);
|
|
expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
|
});
|
|
});
|
|
|
|
it('should NOT trigger geocode if address already has coordinates', async () => {
|
|
mockFetchAddress.mockResolvedValue(mockAddress); // Has coords
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
|
|
|
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
|
|
|
act(() => {
|
|
result.current.handleAddressChange('city', 'NewCity');
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1600);
|
|
});
|
|
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should NOT trigger geocode on initial load, even if address has no coords', async () => {
|
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
|
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
|
const { result } = renderHook(() => useProfileAddress(mockUserProfile, true));
|
|
|
|
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1600);
|
|
});
|
|
|
|
// Should not call because address hasn't changed from initial
|
|
expect(mockGeocode).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|