All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
1099 lines
45 KiB
TypeScript
1099 lines
45 KiB
TypeScript
// src/pages/admin/components/ProfileManager.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock, test } from 'vitest';
|
|
import { ProfileManager } from './ProfileManager';
|
|
import * as apiClient from '../../../services/apiClient';
|
|
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
|
import toast from 'react-hot-toast';
|
|
import * as logger from '../../../services/logger.client';
|
|
import {
|
|
createMockAddress,
|
|
createMockUser,
|
|
createMockUserProfile,
|
|
} from '../../../tests/utils/mockFactories';
|
|
|
|
// Unmock the component to test the real implementation
|
|
vi.unmock('./ProfileManager');
|
|
|
|
vi.mock('../../../components/PasswordInput', () => ({
|
|
// Mock the moved component
|
|
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
|
|
}));
|
|
|
|
// The apiClient, notificationService, react-hot-toast, and logger are all mocked globally.
|
|
// We can get a typed reference to the apiClient for individual test overrides.
|
|
const mockedApiClient = vi.mocked(apiClient, true);
|
|
|
|
const mockOnClose = vi.fn();
|
|
const mockOnLoginSuccess = vi.fn();
|
|
const mockOnSignOut = vi.fn();
|
|
const mockOnProfileUpdate = vi.fn();
|
|
|
|
// --- MOCK DATA ---
|
|
const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'test@example.com' });
|
|
const mockAddressId = 123;
|
|
const authenticatedProfile = createMockUserProfile({
|
|
full_name: 'Test User',
|
|
avatar_url: 'https://example.com/avatar.png',
|
|
role: 'user',
|
|
points: 100,
|
|
preferences: {
|
|
darkMode: false,
|
|
unitSystem: 'imperial',
|
|
},
|
|
user: authenticatedUser,
|
|
address_id: mockAddressId,
|
|
});
|
|
|
|
const mockAddress = createMockAddress({
|
|
address_id: mockAddressId,
|
|
address_line_1: '123 Main St',
|
|
city: 'Anytown',
|
|
province_state: 'ON',
|
|
postal_code: 'A1B 2C3',
|
|
country: 'Canada',
|
|
latitude: 43.0,
|
|
longitude: -79.0,
|
|
});
|
|
|
|
const defaultSignedOutProps = {
|
|
isOpen: true,
|
|
onClose: mockOnClose,
|
|
authStatus: 'SIGNED_OUT' as const,
|
|
userProfile: null,
|
|
onProfileUpdate: mockOnProfileUpdate,
|
|
onSignOut: mockOnSignOut,
|
|
onLoginSuccess: mockOnLoginSuccess,
|
|
};
|
|
|
|
const defaultAuthenticatedProps = {
|
|
isOpen: true,
|
|
onClose: mockOnClose,
|
|
authStatus: 'AUTHENTICATED' as const,
|
|
userProfile: authenticatedProfile,
|
|
onProfileUpdate: mockOnProfileUpdate,
|
|
onSignOut: mockOnSignOut,
|
|
onLoginSuccess: mockOnLoginSuccess,
|
|
};
|
|
|
|
const setupSuccessMocks = () => {
|
|
const mockAuthResponse = { userprofile: authenticatedProfile, token: 'mock-token' };
|
|
(mockedApiClient.loginUser as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify(mockAuthResponse)),
|
|
);
|
|
(mockedApiClient.registerUser as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify(mockAuthResponse)),
|
|
);
|
|
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify({ message: 'Password reset email sent.' })),
|
|
);
|
|
(mockedApiClient.getUserAddress as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify(mockAddress)),
|
|
);
|
|
(mockedApiClient.updateUserProfile as Mock).mockImplementation((data) =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ ...authenticatedProfile, ...(data as object) }),
|
|
} as Response),
|
|
);
|
|
(mockedApiClient.updateUserPassword as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify({ message: 'Password updated successfully.' }), { status: 200 }),
|
|
);
|
|
(mockedApiClient.updateUserPreferences as Mock).mockImplementation((prefs) =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
...authenticatedProfile,
|
|
preferences: { ...authenticatedProfile.preferences, ...prefs },
|
|
}),
|
|
} as Response),
|
|
);
|
|
(mockedApiClient.exportUserData as Mock).mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({ profile: authenticatedProfile, watchedItems: [], shoppingLists: [] }),
|
|
} as Response);
|
|
(mockedApiClient.deleteUserAccount as Mock).mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ message: 'Account deleted successfully.' }),
|
|
} as Response);
|
|
(mockedApiClient.geocodeAddress as Mock).mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ lat: 43.1, lng: -79.1 }),
|
|
});
|
|
(mockedApiClient.updateUserAddress as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify(mockAddress)),
|
|
);
|
|
};
|
|
|
|
describe('ProfileManager', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
setupSuccessMocks();
|
|
// Mock window.confirm for deletion tests
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
// CRITICAL FIX: Reset timers after each test to prevent pollution that causes timeouts.
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// =================================================================
|
|
// == Authentication Flow Tests (Previously ProfileManager.Auth.test.tsx)
|
|
// =================================================================
|
|
describe('Authentication Flows (Signed Out)', () => {
|
|
it('should render the Sign In form when authStatus is SIGNED_OUT', () => {
|
|
render(<ProfileManager {...defaultSignedOutProps} />);
|
|
expect(screen.getByRole('heading', { name: /^sign in$/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call loginUser and onLoginSuccess on successful login', async () => {
|
|
render(<ProfileManager {...defaultSignedOutProps} />);
|
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
|
target: { value: 'user@test.com' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/^password$/i), {
|
|
target: { value: 'securepassword' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.loginUser).toHaveBeenCalledWith(
|
|
'user@test.com',
|
|
'securepassword',
|
|
false,
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(authenticatedProfile, 'mock-token', false);
|
|
expect(mockOnClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should switch to the Create an Account form and register successfully', async () => {
|
|
render(<ProfileManager {...defaultSignedOutProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /register/i }));
|
|
|
|
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New User' } });
|
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
|
target: { value: 'new@test.com' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'newpassword' } });
|
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.registerUser).toHaveBeenCalledWith(
|
|
'new@test.com',
|
|
'newpassword',
|
|
'New User',
|
|
'',
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(mockOnLoginSuccess).toHaveBeenCalled();
|
|
expect(mockOnClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should switch to the Reset Password form and request a reset', async () => {
|
|
render(<ProfileManager {...defaultSignedOutProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
|
|
|
|
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByLabelText(/email address/i), {
|
|
target: { value: 'reset@test.com' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith(
|
|
'reset@test.com',
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
|
|
});
|
|
});
|
|
});
|
|
|
|
// =================================================================
|
|
// == Authenticated User Tests (Previously ProfileManager.Authenticated.test.tsx)
|
|
// =================================================================
|
|
describe('Authenticated User Features', () => {
|
|
it('should render profile tabs when authStatus is AUTHENTICATED', () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
expect(screen.getByRole('heading', { name: /my account/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^profile$/i })).toBeInTheDocument();
|
|
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should close the modal when clicking the backdrop', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
// The backdrop is the element with role="dialog"
|
|
const backdrop = screen.getByRole('dialog');
|
|
fireEvent.click(backdrop);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should reset state when the modal is closed and reopened', async () => {
|
|
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User'));
|
|
|
|
// Change a value
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'A New Name' } });
|
|
expect(screen.getByLabelText(/full name/i)).toHaveValue('A New Name');
|
|
|
|
// "Close" the modal
|
|
rerender(<ProfileManager {...defaultAuthenticatedProps} isOpen={false} />);
|
|
expect(screen.queryByRole('heading', { name: /my account/i })).not.toBeInTheDocument();
|
|
|
|
// "Reopen" the modal
|
|
rerender(<ProfileManager {...defaultAuthenticatedProps} isOpen={true} />);
|
|
await waitFor(() => {
|
|
// The name should be reset to the initial profile value, not 'A New Name'
|
|
expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User');
|
|
});
|
|
});
|
|
|
|
it('should show an error if trying to save profile when not logged in', async () => {
|
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
|
// This is an edge case, but good to test the safeguard
|
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
|
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
|
|
});
|
|
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show a notification if trying to save with no changes', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifySuccess).toHaveBeenCalledWith('No changes to save.');
|
|
});
|
|
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
|
expect(mockedApiClient.updateUserAddress).not.toHaveBeenCalled();
|
|
expect(mockOnClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle failure when fetching user address', async () => {
|
|
console.log('[TEST DEBUG] Running: should handle failure when fetching user address');
|
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
|
mockedApiClient.getUserAddress.mockRejectedValue(new Error('Address not found'));
|
|
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to reject.');
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
await waitFor(() => {
|
|
console.log(
|
|
'[TEST DEBUG] Waiting for assertions. Current logger calls:',
|
|
loggerSpy.mock.calls,
|
|
);
|
|
expect(notifyError).toHaveBeenCalledWith('Address not found');
|
|
// The useProfileAddress hook logs a specific message when the fetch returns null (which useApi does on error)
|
|
expect(loggerSpy).toHaveBeenCalledWith(
|
|
`[useProfileAddress] Fetch returned null for addressId: ${mockAddressId}.`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should handle partial success when saving profile and address', async () => {
|
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
|
// Mock profile update to succeed
|
|
mockedApiClient.updateUserProfile.mockResolvedValue(
|
|
new Response(JSON.stringify({ ...authenticatedProfile, full_name: 'New Name' })),
|
|
);
|
|
// Mock address update to fail (useApi will return null)
|
|
mockedApiClient.updateUserAddress.mockRejectedValue(new Error('Address update failed'));
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
// Change both profile and address data
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
// The useApi hook for the failed call will show its own error
|
|
expect(notifyError).toHaveBeenCalledWith('Address update failed');
|
|
// The profile update should still go through
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ full_name: 'New Name' }),
|
|
);
|
|
// The specific warning for partial failure should be logged
|
|
expect(loggerSpy).toHaveBeenCalledWith(
|
|
'[handleProfileSave] One or more operations failed. The useApi hook should have shown an error. The modal will remain open.',
|
|
);
|
|
// The modal should remain open and no global success message shown
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
|
expect(notifySuccess).not.toHaveBeenCalledWith('Profile updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should handle unexpected critical error during profile save', async () => {
|
|
const loggerSpy = vi.spyOn(logger.logger, 'error');
|
|
mockedApiClient.updateUserProfile.mockRejectedValue(new Error('Catastrophic failure'));
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
// FIX: The useApi hook will catch the error and notify with the raw message.
|
|
expect(notifyError).toHaveBeenCalledWith('Catastrophic failure');
|
|
expect(loggerSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should handle unexpected Promise.allSettled rejection during save', async () => {
|
|
const allSettledSpy = vi
|
|
.spyOn(Promise, 'allSettled')
|
|
.mockRejectedValueOnce(new Error('AllSettled failed'));
|
|
const loggerSpy = vi.spyOn(logger.logger, 'error');
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(loggerSpy).toHaveBeenCalledWith(
|
|
{ err: new Error('AllSettled failed') },
|
|
"[CRITICAL] An unexpected error was caught directly in handleProfileSave's catch block.",
|
|
);
|
|
expect(notifyError).toHaveBeenCalledWith(
|
|
'An unexpected critical error occurred: AllSettled failed',
|
|
);
|
|
});
|
|
|
|
allSettledSpy.mockRestore();
|
|
});
|
|
|
|
it('should show map view when address has coordinates', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view-container')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should not show map view when address has no coordinates', async () => {
|
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
|
mockedApiClient.getUserAddress.mockResolvedValue(
|
|
new Response(JSON.stringify(addressWithoutCoords)),
|
|
);
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('map-view-container')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show error if geocoding is attempted with no address string', async () => {
|
|
mockedApiClient.getUserAddress.mockResolvedValue(new Response(JSON.stringify({})));
|
|
render(
|
|
<ProfileManager
|
|
{...defaultAuthenticatedProps}
|
|
userProfile={{ ...authenticatedProfile, address_id: 999 }}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
// Wait for initial render to settle
|
|
expect(screen.getByRole('button', { name: /re-geocode/i })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(toast.error).toHaveBeenCalledWith(
|
|
'Please fill in the address fields before geocoding.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should automatically geocode address after user stops typing (using fake timers)', async () => {
|
|
// Use fake timers for the entire test to control the debounce.
|
|
vi.useFakeTimers();
|
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
|
mockedApiClient.getUserAddress.mockResolvedValue(
|
|
new Response(JSON.stringify(addressWithoutCoords)),
|
|
);
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
// Wait for initial async address load to complete by flushing promises.
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
|
|
|
|
// Change address, geocode should not be called immediately
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
|
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
|
|
|
|
// Advance timers to fire the debounce and resolve the subsequent geocode promise.
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
// Now check the final result.
|
|
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
|
|
expect.stringContaining('NewCity'),
|
|
expect.anything(),
|
|
);
|
|
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
|
|
});
|
|
|
|
it('should not geocode if address already has coordinates (using fake timers)', async () => {
|
|
// Use real timers for the initial async render and data fetch
|
|
vi.useRealTimers();
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
console.log('[TEST LOG] Waiting for initial address load...');
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
|
|
|
|
// Switch to fake timers to control the debounce check
|
|
vi.useFakeTimers();
|
|
|
|
// Advance timers past the debounce threshold. Nothing should happen.
|
|
act(() => {
|
|
vi.advanceTimersByTime(1600);
|
|
});
|
|
console.log('[TEST LOG] Wait complete. Verifying no geocode call.');
|
|
|
|
// geocode should not have been called because the initial address had coordinates
|
|
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show an error when trying to link an account', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /link google account/i })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /link google account/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith(
|
|
'Account linking with google is not yet implemented.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should show an error when trying to link a GitHub account', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /link github account/i })).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /link github account/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith(
|
|
'Account linking with github is not yet implemented.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should switch between all tabs correctly', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
// Initial state: Profile tab
|
|
expect(screen.getByLabelText('Profile Form')).toBeInTheDocument();
|
|
|
|
// Switch to Security
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
expect(await screen.findByLabelText('New Password')).toBeInTheDocument();
|
|
|
|
// Switch to Data & Privacy
|
|
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
|
expect(await screen.findByRole('heading', { name: /export your data/i })).toBeInTheDocument();
|
|
|
|
// Switch to Preferences
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
expect(await screen.findByRole('heading', { name: /theme/i })).toBeInTheDocument();
|
|
|
|
// Switch back to Profile
|
|
fireEvent.click(screen.getByRole('button', { name: /^profile$/i }));
|
|
expect(await screen.findByLabelText('Profile Form')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show an error if password is too short', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'short' } });
|
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
|
target: { value: 'short' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Password must be at least 6 characters long.');
|
|
});
|
|
expect(mockedApiClient.updateUserPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show an error if account deletion fails', async () => {
|
|
mockedApiClient.deleteUserAccount.mockRejectedValue(new Error('Deletion failed'));
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
|
|
|
|
fireEvent.change(screen.getByTestId('password-input'), {
|
|
target: { value: 'password' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
|
|
|
const confirmButton = await screen.findByRole('button', { name: /yes, delete my account/i });
|
|
fireEvent.click(confirmButton);
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Deletion failed');
|
|
});
|
|
expect(mockOnSignOut).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle toggling dark mode when profile preferences are initially null', async () => {
|
|
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
|
const { rerender } = render(
|
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
|
|
);
|
|
|
|
console.log('[TEST LOG] Clicking preferences tab...');
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
|
|
// FIX: Wait for the tab content to render after the click.
|
|
const darkModeToggle = await screen.findByLabelText(/dark mode/i);
|
|
// Test the ?? false fallback
|
|
expect(darkModeToggle).not.toBeChecked();
|
|
|
|
// Mock the API response for the update
|
|
const updatedProfileWithPrefs = {
|
|
...profileWithoutPrefs,
|
|
preferences: { darkMode: true, unitSystem: 'imperial' as const },
|
|
};
|
|
mockedApiClient.updateUserPreferences.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(updatedProfileWithPrefs),
|
|
} as Response);
|
|
|
|
console.log('[TEST LOG] Clicking dark mode toggle...');
|
|
fireEvent.click(darkModeToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
|
{ darkMode: true },
|
|
expect.anything(),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
|
|
});
|
|
|
|
// Rerender with the new profile to check the UI update
|
|
rerender(
|
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
|
|
);
|
|
|
|
// Wait for the preferences tab to be visible again before checking the toggle
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
expect(await screen.findByLabelText(/dark mode/i)).toBeChecked();
|
|
});
|
|
|
|
it('should allow updating the user profile and address', async () => {
|
|
const updatedProfileData = { ...authenticatedProfile, full_name: 'Updated Name' };
|
|
const updatedAddressData = { ...mockAddress, city: 'NewCity' };
|
|
|
|
mockedApiClient.updateUserProfile.mockResolvedValue(
|
|
new Response(JSON.stringify(updatedProfileData)),
|
|
);
|
|
mockedApiClient.updateUserAddress.mockResolvedValue(
|
|
new Response(JSON.stringify(updatedAddressData)),
|
|
);
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name),
|
|
);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
|
|
|
const saveButton = screen.getByRole('button', { name: /save profile/i });
|
|
fireEvent.click(saveButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith(
|
|
{ full_name: 'Updated Name', avatar_url: authenticatedProfile.avatar_url },
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
|
|
expect.objectContaining({ city: 'NewCity' }),
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ full_name: 'Updated Name' }),
|
|
);
|
|
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should show an error if updating the address fails but profile succeeds', async () => {
|
|
mockedApiClient.updateUserProfile.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(authenticatedProfile), { status: 200 }),
|
|
);
|
|
mockedApiClient.updateUserAddress.mockRejectedValueOnce(new Error('Address update failed'));
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
// Change both profile and address data
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
// The useApi hook will show the error for the failed call
|
|
expect(notifyError).toHaveBeenCalledWith('Address update failed');
|
|
});
|
|
|
|
// The success notification should NOT be called because one of the promises failed
|
|
expect(notifySuccess).not.toHaveBeenCalled();
|
|
// FIX: The component logic is now corrected to call onProfileUpdate on partial success.
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(authenticatedProfile);
|
|
// The modal should remain open
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow updating the password', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
fireEvent.change(screen.getByLabelText('New Password'), {
|
|
target: { value: 'newpassword123' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
|
target: { value: 'newpassword123' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith(
|
|
'newpassword123',
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(notifySuccess).toHaveBeenCalledWith('Password updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should show an error if passwords do not match', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
fireEvent.change(screen.getByLabelText('New Password'), {
|
|
target: { value: 'newpassword123' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
|
target: { value: 'mismatch' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('update-password-form'), {});
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Passwords do not match.');
|
|
});
|
|
expect(mockedApiClient.updateUserPassword).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should trigger data export', async () => {
|
|
const anchorClickSpy = vi
|
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
.mockImplementation(() => {});
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /export my data/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.exportUserData).toHaveBeenCalled();
|
|
expect(anchorClickSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
anchorClickSpy.mockRestore();
|
|
});
|
|
|
|
it('should handle account deletion flow', async () => {
|
|
// Use fake timers to control the setTimeout call for the entire test.
|
|
vi.useFakeTimers();
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
|
|
|
|
// Open the confirmation section
|
|
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
|
|
expect(
|
|
screen.getByText(/to confirm, please enter your current password/i),
|
|
).toBeInTheDocument();
|
|
|
|
// Fill password and submit to open modal
|
|
fireEvent.change(screen.getByTestId('password-input'), {
|
|
target: { value: 'correctpassword' },
|
|
});
|
|
fireEvent.submit(screen.getByTestId('delete-account-form'));
|
|
|
|
// Confirm in the modal
|
|
// Use getByRole since the modal appears synchronously after the form submit.
|
|
const confirmButton = screen.getByRole('button', { name: /yes, delete my account/i });
|
|
fireEvent.click(confirmButton);
|
|
|
|
// The async deleteAccount call is now pending. We need to flush promises
|
|
// and then advance the timers to run the subsequent setTimeout.
|
|
// `runAllTimersAsync` will resolve pending promises and run timers recursively.
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
// Now that all timers and promises have been flushed, we can check the final state.
|
|
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalled();
|
|
expect(notifySuccess).toHaveBeenCalled();
|
|
expect(mockOnClose).toHaveBeenCalled();
|
|
expect(mockOnSignOut).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow toggling dark mode', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
|
|
const darkModeToggle = screen.getByLabelText(/dark mode/i);
|
|
expect(darkModeToggle).not.toBeChecked();
|
|
|
|
fireEvent.click(darkModeToggle);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
|
{ darkMode: true },
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ preferences: expect.objectContaining({ darkMode: true }) }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow changing the unit system', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
|
|
const metricRadio = screen.getByLabelText(/metric/i);
|
|
fireEvent.click(metricRadio);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
|
{ unitSystem: 'metric' },
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
preferences: expect.objectContaining({ unitSystem: 'metric' }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow changing unit system when preferences are initially null', async () => {
|
|
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
|
const { rerender } = render(
|
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
|
|
const imperialRadio = await screen.findByLabelText(/imperial/i);
|
|
const metricRadio = screen.getByLabelText(/metric/i);
|
|
|
|
// With null preferences, neither should be checked.
|
|
expect(imperialRadio).not.toBeChecked();
|
|
expect(metricRadio).not.toBeChecked();
|
|
|
|
// Mock the API response for the update
|
|
const updatedProfileWithPrefs = {
|
|
...profileWithoutPrefs,
|
|
preferences: { darkMode: false, unitSystem: 'metric' as const },
|
|
};
|
|
mockedApiClient.updateUserPreferences.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(updatedProfileWithPrefs),
|
|
} as Response);
|
|
|
|
fireEvent.click(metricRadio);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
|
{ unitSystem: 'metric' },
|
|
expect.anything(),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
|
|
});
|
|
|
|
// Rerender with the new profile to check the UI update
|
|
rerender(
|
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
expect(await screen.findByLabelText(/metric/i)).toBeChecked();
|
|
expect(screen.getByLabelText(/imperial/i)).not.toBeChecked();
|
|
});
|
|
|
|
it('should not call onProfileUpdate if updating unit system fails', async () => {
|
|
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
|
const metricRadio = await screen.findByLabelText(/metric/i);
|
|
fireEvent.click(metricRadio);
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('API failed');
|
|
});
|
|
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should only call updateProfile when only profile data has changed', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), {
|
|
target: { value: 'Only Name Changed' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserProfile).toHaveBeenCalled();
|
|
expect(mockedApiClient.updateUserAddress).not.toHaveBeenCalled();
|
|
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should only call updateAddress when only address data has changed', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Only City Changed' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserAddress).toHaveBeenCalled();
|
|
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
|
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should handle manual geocode success via button click', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
// Mock geocode response for the manual trigger
|
|
(mockedApiClient.geocodeAddress as Mock).mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ lat: 50.0, lng: -80.0 }),
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.geocodeAddress).toHaveBeenCalled();
|
|
expect(toast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
|
|
});
|
|
});
|
|
|
|
it('should reset address form if profile has no address_id', async () => {
|
|
const profileNoAddress = { ...authenticatedProfile, address_id: null };
|
|
render(
|
|
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileNoAddress as any} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
// Address fields should be empty
|
|
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('');
|
|
// Should not attempt to fetch address
|
|
expect(mockedApiClient.getUserAddress).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should not render auth views when the user is already authenticated', () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Create an Account')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should log warning if address fetch returns null', async () => {
|
|
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
|
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
|
// Mock getUserAddress to return a successful response with a null body,
|
|
// which useApi will parse to null.
|
|
(mockedApiClient.getUserAddress as Mock).mockResolvedValue(
|
|
new Response(JSON.stringify(null)),
|
|
);
|
|
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to resolve with a null body.');
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
await waitFor(() => {
|
|
console.log(
|
|
'[TEST DEBUG] Waiting for assertions. Current logger calls:',
|
|
loggerSpy.mock.calls,
|
|
);
|
|
expect(loggerSpy).toHaveBeenCalledWith(
|
|
`[useProfileAddress] Fetch returned null for addressId: ${mockAddressId}.`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should handle updating the user profile and address with empty strings', async () => {
|
|
mockedApiClient.updateUserProfile.mockImplementation(async (data) =>
|
|
new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
|
|
);
|
|
mockedApiClient.updateUserAddress.mockImplementation(async (data) =>
|
|
new Response(JSON.stringify({ ...mockAddress, ...data })),
|
|
);
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name);
|
|
});
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: '' } });
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: '' } });
|
|
|
|
const saveButton = screen.getByRole('button', { name: /save profile/i });
|
|
fireEvent.click(saveButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith(
|
|
{ full_name: '', avatar_url: authenticatedProfile.avatar_url },
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
|
|
expect.objectContaining({ city: '' }),
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ full_name: '' })
|
|
);
|
|
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
|
|
});
|
|
});
|
|
|
|
it('should correctly clear the form when userProfile.address_id is null', async () => {
|
|
const profileNoAddress = { ...authenticatedProfile, address_id: null };
|
|
render(
|
|
<ProfileManager
|
|
{...defaultAuthenticatedProps}
|
|
userProfile={profileNoAddress as any} // Forcefully override the type to simulate address_id: null
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('');
|
|
expect(screen.getByLabelText(/city/i)).toHaveValue('');
|
|
expect(screen.getByLabelText(/province \/ state/i)).toHaveValue('');
|
|
expect(screen.getByLabelText(/postal \/ zip code/i)).toHaveValue('');
|
|
expect(screen.getByLabelText(/country/i)).toHaveValue('');
|
|
});
|
|
});
|
|
|
|
it('should show error notification when manual geocoding fails', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Geocoding failed'));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Geocoding failed');
|
|
});
|
|
});
|
|
|
|
it('should show error notification when auto-geocoding fails', async () => {
|
|
vi.useFakeTimers();
|
|
// FIX: Mock getUserAddress to return an address *without* coordinates.
|
|
// This is the condition required to trigger the auto-geocoding logic.
|
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
|
mockedApiClient.getUserAddress.mockResolvedValue(
|
|
new Response(JSON.stringify(addressWithoutCoords)),
|
|
);
|
|
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
|
|
// Wait for initial load
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Auto-geocode error'));
|
|
|
|
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'ErrorCity' } });
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
expect(notifyError).toHaveBeenCalledWith('Auto-geocode error');
|
|
});
|
|
|
|
it('should handle permission denied error during geocoding', async () => {
|
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
|
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
|
|
|
|
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Permission denied'));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(notifyError).toHaveBeenCalledWith('Permission denied');
|
|
});
|
|
});
|
|
|
|
it('should not trigger OAuth link if user profile is missing', async () => {
|
|
// This is an edge case to test the guard clause in handleOAuthLink
|
|
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
|
|
|
const linkButton = await screen.findByRole('button', { name: /link google account/i });
|
|
fireEvent.click(linkButton);
|
|
|
|
// The function should just return, so nothing should happen.
|
|
await waitFor(() => {
|
|
expect(notifyError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|