Files
flyer-crawler.projectium.com/src/pages/admin/components/ProfileManager.test.tsx
Torben Sorensen 2564df1c64
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
get rid of localhost in tests - not a qualified URL - we'll see
2026-01-05 20:02:44 -08:00

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();
});
});
});
});