many fixes resulting from latest refactoring
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m6s

This commit is contained in:
2025-12-09 00:44:45 -08:00
parent 88fdb9886f
commit 33e55500a7
8 changed files with 439 additions and 420 deletions

View File

@@ -1,31 +1,23 @@
// src/pages/admin/components/AdminBrandManager.tsx
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
import { Brand } from '../../../types';
import { ErrorDisplay } from '../../../components/ErrorDisplay';
import { useApiOnMount } from '../../../hooks/useApiOnMount';
export const AdminBrandManager: React.FC = () => {
// Use the new hook to fetch brands when the component mounts.
// It handles loading and error states for us.
const { data: initialBrands, loading, error } = useApiOnMount<Brand[], []>(fetchAllBrands, []);
// We still need local state for brands so we can update it after a logo upload
// without needing to re-fetch the entire list.
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadBrands = async () => {
try {
setLoading(true);
const response = await fetchAllBrands();
const fetchedBrands = await response.json();
setBrands(fetchedBrands);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Failed to load brands: ${errorMessage}`);
} finally {
setLoading(false);
}
};
loadBrands();
}, []);
if (initialBrands) setBrands(initialBrands);
}, [initialBrands]);
const handleLogoUpload = async (brandId: number, file: File) => {
if (!file) {
@@ -67,7 +59,7 @@ export const AdminBrandManager: React.FC = () => {
}
if (error) {
return <ErrorDisplay message={error} />;
return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />;
}
return (

View File

@@ -1,4 +1,4 @@
// src/pages/admin/ProfileManager.test.tsx
// src/pages/admin/components/ProfileManager.Auth.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
@@ -24,33 +24,7 @@ const defaultProps = {
onLoginSuccess: mockOnLoginSuccess,
};
// Define authenticated user data at the top level to be accessible by all describe blocks.
const authenticatedUser = { user_id: 'auth-user-123', email: 'test@example.com' };
const authenticatedProfile = {
user_id: 'auth-user-123',
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png',
role: 'user' as const,
points: 100,
preferences: {
darkMode: false,
unitSystem: 'imperial' as const,
},
};
const authenticatedProps = {
isOpen: true,
onClose: mockOnClose,
user: authenticatedUser,
authStatus: 'AUTHENTICATED' as const,
profile: authenticatedProfile,
onProfileUpdate: mockOnProfileUpdate,
onSignOut: mockOnSignOut,
onLoginSuccess: mockOnLoginSuccess,
};
// A helper function to set up all default successful API mocks.
// This ensures a consistent starting state for each test block.
const setupSuccessMocks = () => {
const mockAuthResponse = {
user: { user_id: '123', email: 'test@example.com' },
@@ -61,13 +35,6 @@ const setupSuccessMocks = () => {
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(
new Response(JSON.stringify({ message: 'Password reset email sent.' }))
);
(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);
};
describe('ProfileManager Authentication Flows', () => {
@@ -77,9 +44,8 @@ describe('ProfileManager Authentication Flows', () => {
setupSuccessMocks();
});
// Restore all mocks after each test to ensure test isolation.
afterEach(() => {
cleanup(); // vi.clearAllMocks() is already called in beforeEach, and spies are restored manually.
cleanup();
});
// --- Initial Render (Signed Out) ---
@@ -138,7 +104,7 @@ describe('ProfileManager Authentication Flows', () => {
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Invalid credentials');
expect(notifyError).toHaveBeenCalledWith('Login failed: Invalid credentials');
});
expect(mockOnLoginSuccess).not.toHaveBeenCalled();
expect(mockOnClose).not.toHaveBeenCalled();
@@ -243,7 +209,7 @@ describe('ProfileManager Authentication Flows', () => {
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Email already in use');
expect(notifyError).toHaveBeenCalledWith('Registration failed: Email already in use');
});
expect(mockOnLoginSuccess).not.toHaveBeenCalled();
expect(mockOnClose).not.toHaveBeenCalled();
@@ -288,7 +254,7 @@ describe('ProfileManager Authentication Flows', () => {
fireEvent.submit(screen.getByTestId('reset-password-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('User not found');
expect(notifyError).toHaveBeenCalledWith('Failed to send reset link: User not found');
});
});
@@ -339,236 +305,12 @@ describe('ProfileManager Authentication Flows', () => {
Object.defineProperty(window, 'location', { writable: true, value: originalLocation });
});
// --- Authenticated View ---
it('should render profile tabs when authStatus is AUTHENTICATED', () => {
render(
<ProfileManager
{...defaultProps}
user={{ user_id: '123', email: 'authenticated@example.com' }}
authStatus="AUTHENTICATED"
profile={{ user_id: '123', full_name: 'Test User', role: 'user', points: 100 }}
/>
);
// --- Authenticated View (Negative Test) ---
it('should NOT render profile tabs when authStatus is SIGNED_OUT', () => {
render(<ProfileManager {...defaultProps} />);
expect(screen.getByRole('heading', { name: /my account/i })).toBeInTheDocument();
// The tabs are rendered as buttons, so we query for the 'button' role.
// This is a pragmatic fix for the test based on the current component implementation.
expect(screen.getByRole('button', { name: /^profile$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /security/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /data & privacy/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /preferences/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
});
});
describe('ProfileManager Authenticated User Features', () => {
// Restore all spies after each test to ensure test isolation
beforeEach(() => {
vi.clearAllMocks();
// Mock successful API calls by default
setupSuccessMocks();
});
// Restore all spies after each test to ensure test isolation
afterEach(() => {
cleanup(); // vi.clearAllMocks() is already called in beforeEach, and spies are restored manually.
});
// --- Profile Tab ---
it('should allow updating the user profile', async () => {
// --- FIX LEDGER ---
// 1. Standard mockResolvedValue. Failed (callback not called).
// 2. Attempt: Return `new Response()`. Failed (callback not called).
// 3. Current Strategy: Return plain object with `ok` and `json` methods to simulate Response.
// Also reset mocks to ensure no contamination.
const updatedProfileData = { ...authenticatedProfile, full_name: 'Updated Name' };
// Reset mocks to ensure clean state
vi.mocked(mockedApiClient.updateUserProfile).mockReset();
vi.mocked(mockedApiClient.updateUserAddress).mockReset();
vi.mocked(mockedApiClient.updateUserProfile).mockResolvedValue({
ok: true,
json: async () => updatedProfileData,
} as Response);
vi.mocked(mockedApiClient.updateUserAddress).mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response);
render(<ProfileManager {...authenticatedProps} />);
const nameInput = screen.getByLabelText(/full name/i);
fireEvent.change(nameInput, { target: { value: 'Updated Name' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalled();
expect(mockOnProfileUpdate).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated Name' }));
// Match any success message containing "Profile" and "updated"
expect(notifySuccess).toHaveBeenCalledWith(expect.stringMatching(/Profile.*updated/));
});
});
it('should show an error if updating the profile fails', async () => {
// Mock the API to return a failed response
(mockedApiClient.updateUserProfile as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Server is down' }), { status: 500 })
);
render(<ProfileManager {...authenticatedProps} />);
// The profile tab is active by default, but we can click it to be explicit.
// We use an exact match regex to distinguish from the "Save Profile" button.
const profileTab = screen.getByRole('button', { name: /^profile$/i });
fireEvent.click(profileTab);
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'A Name That Will Fail' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
// Check that the error toast is displaye
expect(notifyError).toHaveBeenCalledWith('Server is down');
});
// Ensure the success callback was NOT called
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
});
// --- Security Tab ---
it('should allow updating the password', async () => {
render(<ProfileManager {...authenticatedProps} />);
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(notifySuccess).toHaveBeenCalledWith('Password updated successfully!');
});
});
it('should show an error if passwords do not match', async () => {
render(<ProfileManager {...authenticatedProps} />);
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();
});
// --- Data & Privacy Tab ---
it('should trigger data export', async () => {
// To test the download functionality without interfering with React's rendering,
// we spy on the `click` method of the anchor element's prototype.
// This is safer than mocking `document.createElement`.
const anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
// We provide an empty implementation to prevent the test environment from trying to navigate.
});
render(<ProfileManager {...authenticatedProps} />);
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();
});
// Manually restore the spy to avoid polluting other tests.
anchorClickSpy.mockRestore();
});
it('should handle account deletion flow', async () => {
const { unmount } = render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), { target: { value: 'correctpassword' } });
fireEvent.submit(screen.getByTestId('delete-account-form'));
const confirmButton = await screen.findByRole('button', { name: /yes, delete my account/i });
fireEvent.click(confirmButton);
// Wait for the initial API call and success message.
await waitFor(() => {
expect(mockedApiClient.deleteUserAccount).toHaveBeenCalledWith('correctpassword');
expect(notifySuccess).toHaveBeenCalledWith("Account deleted successfully. You will be logged out shortly.");
});
// Now, wait for the delayed sign-out to occur, giving waitFor a longer timeout.
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnSignOut).toHaveBeenCalled();
}, { timeout: 3500 }); // Wait longer than the component's 3000ms setTimeout.
unmount();
});
it('should show an error on account deletion with wrong password', async () => {
(mockedApiClient.deleteUserAccount as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Incorrect password.' }), { status: 401 })
);
render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), { target: { value: 'wrongpassword' } });
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('Incorrect password.');
});
expect(mockOnSignOut).not.toHaveBeenCalled();
});
// --- Preferences Tab ---
it('should allow toggling dark mode', async () => {
render(<ProfileManager {...authenticatedProps} />);
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(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ preferences: expect.objectContaining({ darkMode: true }) })
);
});
});
it('should allow changing the unit system', async () => {
render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const imperialRadio = screen.getByLabelText(/imperial/i);
const metricRadio = screen.getByLabelText(/metric/i);
expect(imperialRadio).toBeChecked();
expect(metricRadio).not.toBeChecked();
fireEvent.click(metricRadio);
await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({ unitSystem: 'metric' });
expect(mockOnProfileUpdate).toHaveBeenCalledWith(expect.objectContaining({ preferences: expect.objectContaining({ unitSystem: 'metric' }) }));
});
expect(screen.queryByRole('heading', { name: /my account/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^profile$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /security/i })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,316 @@
// src/pages/admin/components/ProfileManager.Authenticated.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { ProfileManager } from './ProfileManager';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
const mockedApiClient = vi.mocked(apiClient, true);
const mockOnClose = vi.fn();
const mockOnLoginSuccess = vi.fn();
const mockOnSignOut = vi.fn();
const mockOnProfileUpdate = vi.fn();
// Define authenticated user data at the top level to be accessible by all describe blocks.
const authenticatedUser = { user_id: 'auth-user-123', email: 'test@example.com' };
const mockAddressId = 123;
const authenticatedProfile = {
user_id: 'auth-user-123',
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png',
role: 'user' as const,
points: 100,
preferences: {
darkMode: false,
unitSystem: 'imperial' as const,
},
address_id: mockAddressId, // Add address_id to ensure address fetching is triggered
};
const mockAddress = {
address_id: mockAddressId,
user_id: 'auth-user-123',
address_line_1: '123 Main St',
city: 'Anytown',
province_state: 'ON',
postal_code: 'A1B 2C3',
country: 'Canada',
latitude: 43.0,
longitude: -79.0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const authenticatedProps = {
isOpen: true,
onClose: mockOnClose,
user: authenticatedUser,
authStatus: 'AUTHENTICATED' as const,
profile: authenticatedProfile,
onProfileUpdate: mockOnProfileUpdate,
onSignOut: mockOnSignOut,
onLoginSuccess: mockOnLoginSuccess,
};
// A helper function to set up all default successful API mocks.
const setupSuccessMocks = () => {
// Mock getUserAddress to return a valid address when called
(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);
};
describe('ProfileManager Authenticated User Features', () => {
beforeEach(() => {
vi.clearAllMocks();
setupSuccessMocks();
});
afterEach(() => {
cleanup();
});
// --- Authenticated View ---
it('should render profile tabs when authStatus is AUTHENTICATED', () => {
render(<ProfileManager {...authenticatedProps} />);
expect(screen.getByRole('heading', { name: /my account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^profile$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /security/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /data & privacy/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /preferences/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
});
// --- Profile Tab ---
it('should allow updating the user profile and address', async () => {
// Mock API calls for this specific test
const updatedProfileData = { ...authenticatedProfile, full_name: 'Updated Name' };
const updatedAddressData = { ...mockAddress, city: 'NewCity' };
// Use manually-resolvable promises for updateUserProfile and updateUserAddress to control loading state
let resolveProfileUpdate: (value: Response) => void = null!;
const profileUpdatePromise = new Promise<Response>(resolve => { resolveProfileUpdate = resolve; });
vi.mocked(mockedApiClient.updateUserProfile).mockReturnValue(profileUpdatePromise);
let resolveAddressUpdate: (value: Response) => void = null!;
const addressUpdatePromise = new Promise<Response>(resolve => { resolveAddressUpdate = resolve; });
vi.mocked(mockedApiClient.updateUserAddress).mockReturnValue(addressUpdatePromise);
render(<ProfileManager {...authenticatedProps} />);
// Wait for initial data fetch (getUserAddress) to complete and component to render with initial values
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name));
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Get elements after initial render
const nameInput = screen.getByLabelText(/full name/i);
const cityInput = screen.getByLabelText(/city/i);
const saveButton = screen.getByRole('button', { name: /save profile/i });
// Change inputs
fireEvent.change(nameInput, { target: { value: 'Updated Name' } });
fireEvent.change(cityInput, { target: { value: 'NewCity' } });
// Assert that the button is not disabled before click
expect(saveButton).not.toBeDisabled();
fireEvent.click(saveButton);
// Assert that the button is disabled immediately after click
await waitFor(() => expect(saveButton).toBeDisabled());
// Resolve the promises
resolveProfileUpdate(new Response(JSON.stringify(updatedProfileData)));
resolveAddressUpdate(new Response(JSON.stringify(updatedAddressData)));
// Wait for the updates to complete and assertions
await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({ full_name: 'Updated Name', avatar_url: authenticatedProfile.avatar_url });
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(expect.objectContaining({ ...mockAddress, city: 'NewCity' }));
expect(mockOnProfileUpdate).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated Name' }));
expect(notifySuccess).toHaveBeenCalledWith(expect.stringMatching(/Profile.*updated/));
});
});
it('should show an error if updating the profile fails', async () => {
(mockedApiClient.updateUserProfile as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Profile update failed' }), { status: 500 })
);
// Ensure address update succeeds so we isolate the profile update failure
vi.mocked(mockedApiClient.updateUserAddress).mockResolvedValueOnce(
new Response(JSON.stringify(mockAddress), { status: 200 })
);
render(<ProfileManager {...authenticatedProps} />);
// Wait for initial data fetch (getUserAddress) to complete
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Failed to update profile: Profile update failed');
});
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should show an error if updating the address fails', async () => {
// Ensure profile update succeeds so we isolate the address update failure
vi.mocked(mockedApiClient.updateUserProfile).mockResolvedValueOnce(
new Response(JSON.stringify(authenticatedProfile), { status: 200 })
);
(mockedApiClient.updateUserAddress as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Address update failed' }), { status: 500 })
);
render(<ProfileManager {...authenticatedProps} />);
// Wait for initial data fetch (getUserAddress) to complete
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Failed to update address: Address update failed');
});
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
});
// --- Security Tab ---
it('should allow updating the password', async () => {
render(<ProfileManager {...authenticatedProps} />);
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(notifySuccess).toHaveBeenCalledWith('Password updated successfully!');
});
});
it('should show an error if passwords do not match', async () => {
render(<ProfileManager {...authenticatedProps} />);
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();
});
// --- Data & Privacy Tab ---
it('should trigger data export', async () => {
const anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
render(<ProfileManager {...authenticatedProps} />);
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 () => {
const { unmount } = render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), { target: { value: 'correctpassword' } });
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(mockedApiClient.deleteUserAccount).toHaveBeenCalledWith('correctpassword');
expect(notifySuccess).toHaveBeenCalledWith("Account deleted successfully. You will be logged out shortly.");
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
expect(mockOnSignOut).toHaveBeenCalled();
}, { timeout: 3500 });
unmount();
});
it('should show an error on account deletion with wrong password', async () => {
(mockedApiClient.deleteUserAccount as Mock).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Incorrect password.' }), { status: 401 })
);
render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), { target: { value: 'wrongpassword' } });
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('Failed to delete account: Incorrect password.');
});
expect(mockOnSignOut).not.toHaveBeenCalled();
});
// --- Preferences Tab ---
it('should allow toggling dark mode', async () => {
render(<ProfileManager {...authenticatedProps} />);
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(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ preferences: expect.objectContaining({ darkMode: true }) })
);
});
});
it('should allow changing the unit system', async () => {
render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const imperialRadio = screen.getByLabelText(/imperial/i);
const metricRadio = screen.getByLabelText(/metric/i);
expect(imperialRadio).toBeChecked();
expect(metricRadio).not.toBeChecked();
fireEvent.click(metricRadio);
await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({ unitSystem: 'metric' });
expect(mockOnProfileUpdate).toHaveBeenCalledWith(expect.objectContaining({ preferences: expect.objectContaining({ unitSystem: 'metric' }) }));
});
});
});

View File

@@ -1,5 +1,5 @@
// src/pages/admin/components/ProfileManager.tsx
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import type { Profile, Address, User } from '../../../types';
import { useApi } from '../../../hooks/useApi';
@@ -39,10 +39,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
// Profile state
const [fullName, setFullName] = useState(profile?.full_name || '');
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(apiClient.updateUserProfile);
const [isGeocoding, setIsGeocoding] = useState(false);
const [address, setAddress] = useState<Partial<Address>>({});
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(apiClient.updateUserProfile);
const { execute: updateAddress, loading: addressLoading } = useApi<Address, [Partial<Address>]>(apiClient.updateUserAddress);
const { execute: geocode, loading: isGeocoding } = useApi<{ lat: number; lng: number }, [string]>(apiClient.geocodeAddress);
// Password state
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -71,6 +73,15 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const { execute: executeRegister, loading: registerLoading } = useApi<AuthResponse, [string, string, string, string]>(apiClient.registerUser);
const { execute: executePasswordReset, loading: passwordResetLoading } = useApi<{ message: string }, [string]>(apiClient.requestPasswordReset);
// New hook to fetch address details
const { execute: fetchAddress } = useApi<Address, [number]>(apiClient.getUserAddress);
const handleAddressFetch = useCallback(async (addressId: number) => {
const fetchedAddress = await fetchAddress(addressId);
if (fetchedAddress) {
setAddress(fetchedAddress);
}
}, [fetchAddress]);
useEffect(() => {
// Only reset state when the modal is opened.
@@ -80,10 +91,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
setAvatarUrl(profile?.avatar_url || '');
// If the user has an address, fetch its details
if (profile.address_id) {
apiClient.getUserAddress(profile.address_id)
.then((res: Response) => res.json())
.then((data: Address) => setAddress(data))
.catch((err: Error) => toast.error(`Could not load address details: ${err.message}`));
handleAddressFetch(profile.address_id);
} else {
// Reset address form if user has no address
setAddress({});
@@ -101,7 +109,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
} else {
setAddress({});
}
}, [isOpen, profile]); // Depend on isOpen and profile
}, [isOpen, profile, handleAddressFetch]); // Depend on isOpen and profile
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
@@ -115,15 +123,15 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const profileUpdatePromise = updateProfile({
full_name: fullName,
avatar_url: avatarUrl,
});
const addressUpdatePromise = apiClient.updateUserAddress(address);
});
const addressUpdatePromise = updateAddress(address);
const [profileResponse] = await Promise.all([
profileUpdatePromise,
addressUpdatePromise
]);
if (profileResponse) {
if (profileResponse) { // profileResponse can be null if useApi fails
onProfileUpdate(profileResponse);
}
notifySuccess('Profile and address updated successfully!');
@@ -153,16 +161,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
return;
}
setIsGeocoding(true);
try {
const response = await apiClient.geocodeAddress(addressString);
const { lat, lng } = await response.json();
const result = await geocode(addressString);
if (result) {
const { lat, lng } = result;
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
toast.success('Address re-geocoded successfully!');
} catch (error) {
toast.error(`Failed to re-geocode address: ${(error as Error).message}.`);
} finally {
setIsGeocoding(false);
}
};
// --- Automatic Geocoding Logic ---
@@ -185,16 +188,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
return;
}
setIsGeocoding(true);
try {
const response = await apiClient.geocodeAddress(addressString);
const { lat, lng } = await response.json();
const result = await geocode(addressString);
if (result) {
const { lat, lng } = result;
setAddress(prev => ({ ...prev, latitude: lat, longitude: lng }));
toast.success('Address geocoded successfully!');
} catch (error) {
toast.error(`Failed to geocode address: ${(error as Error).message}.`);
} finally {
setIsGeocoding(false);
}
};
@@ -471,13 +469,13 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<AddressForm address={address} onAddressChange={handleAddressChange} onGeocode={handleManualGeocode} isGeocoding={isGeocoding} />
</div>
{address.latitude && address.longitude && (
<div className="pt-4">
<div className="pt-4" data-testid="map-view-container">
<MapView latitude={address.latitude} longitude={address.longitude} />
</div>
)}
<div className="pt-2">
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
<button type="submit" disabled={profileLoading || addressLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{(profileLoading || addressLoading) ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
</button>
</div>
</form>

View File

@@ -176,7 +176,7 @@ describe('SystemCheck', () => {
render(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Login failed. Ensure the default admin user is seeded in your database.')).toBeInTheDocument();
expect(screen.getByText('Incorrect email or password')).toBeInTheDocument();
});
});

View File

@@ -4,8 +4,8 @@
* It communicates with the application's own backend endpoints, which then securely
* call the Google AI services. This ensures no API keys are exposed on the client.
*/
import type { FlyerItem, Store } from "../types";
import { logger } from "./logger";
import type { FlyerItem, Store } from '../types';
import { logger } from './logger.client'; // Corrected import path for client-side logger
import { apiFetch } from './apiClient';
/**

View File

@@ -1,50 +0,0 @@
// src/services/geminiService.ts
// This file is intended for client-side Gemini helper functions.
// Currently, most AI logic is handled on the server via `aiService.server.ts`
// and exposed through the API, so this file is largely a placeholder for future features.
// import { logger } from "./logger";
// /**
// * Parses a JSON string from a Gemini response, robustly handling markdown fences.
// * @param responseText The raw text from the AI response.
// * @returns The parsed JSON object.
// */
// function parseGeminiJson<T>(responseText: string): T {
// let cleanedText = responseText.trim();
// // Remove markdown fences ` ```json ... ``` `
// const jsonRegex = /```json\s*([\s\S]*?)\s*```/;
// const match = cleanedText.match(jsonRegex);
// if (match && match[1]) {
// cleanedText = match[1];
// }
// try {
// return JSON.parse(cleanedText) as T;
// } catch (e) {
// // Ensure we handle different types of thrown errors gracefully.
// const errorMessage = e instanceof Error ? e.message : String(e);
// logger.error("Failed to parse JSON response from AI.", {
// originalResponse: responseText,
// cleanedJSON: cleanedText,
// error: errorMessage,
// });
// // Re-throw with more context.
// throw new Error(`Failed to parse JSON response from AI. Error: ${errorMessage}. The AI may have returned malformed data.`);
// }
// }
// ============================================================================
// STUBS FOR FUTURE AI FEATURES
// ============================================================================
/**
* [STUB] Uses Google Maps grounding to find nearby stores and plan a shopping trip.
* @param items The items from the flyer.
* @param store The store associated with the flyer.
* @param userLocation The user's current geographic coordinates.
* @returns A text response with trip planning advice and a list of map sources.
*/

View File

@@ -267,59 +267,79 @@ vi.mock('react-hot-toast', () => ({
// --- Database Service Mocks ---
vi.mock('../../services/db/user.db', () => ({
findUserByEmail: vi.fn(),
createUser: vi.fn(),
findUserById: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPreferences: vi.fn(),
updateUserPassword: vi.fn(),
deleteUserById: vi.fn(),
saveRefreshToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
deleteResetToken: vi.fn(),
exportUserData: vi.fn(),
followUser: vi.fn(),
unfollowUser: vi.fn(),
getUserFeed: vi.fn(),
logSearchQuery: vi.fn(),
resetFailedLoginAttempts: vi.fn(),
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
}));
vi.mock('../../services/db/user.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/user.db')>();
return {
...actual,
findUserByEmail: vi.fn(),
createUser: vi.fn(),
findUserById: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPreferences: vi.fn(),
updateUserPassword: vi.fn(),
deleteUserById: vi.fn(),
saveRefreshToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
deleteResetToken: vi.fn(),
exportUserData: vi.fn(),
followUser: vi.fn(),
unfollowUser: vi.fn(),
getUserFeed: vi.fn(),
logSearchQuery: vi.fn(),
resetFailedLoginAttempts: vi.fn(),
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
};
});
vi.mock('../../services/db/budget.db', () => ({
getBudgetsForUser: vi.fn().mockResolvedValue([]),
createBudget: vi.fn(),
updateBudget: vi.fn(),
deleteBudget: vi.fn(),
getSpendingByCategory: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../services/db/budget.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/budget.db')>();
return {
...actual,
getBudgetsForUser: vi.fn().mockResolvedValue([]),
createBudget: vi.fn(),
updateBudget: vi.fn(),
deleteBudget: vi.fn(),
getSpendingByCategory: vi.fn().mockResolvedValue([]),
};
});
vi.mock('../../services/db/gamification.db', () => ({
getAllAchievements: vi.fn().mockResolvedValue([]),
getUserAchievements: vi.fn().mockResolvedValue([]),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../services/db/gamification.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/gamification.db')>();
return {
...actual,
getAllAchievements: vi.fn().mockResolvedValue([]),
getUserAchievements: vi.fn().mockResolvedValue([]),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn().mockResolvedValue([]),
};
});
vi.mock('../../services/db/notification.db', () => ({
createNotification: vi.fn(),
createBulkNotifications: vi.fn(),
getNotificationsForUser: vi.fn().mockResolvedValue([]),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
}));
vi.mock('../../services/db/notification.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/db/notification.db')>();
return {
...actual,
createNotification: vi.fn(),
createBulkNotifications: vi.fn(),
getNotificationsForUser: vi.fn().mockResolvedValue([]),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
};
});
// --- Server-Side Service Mocks ---
vi.mock('../../services/aiService.server', () => ({
// The singleton instance is named `aiService`. We mock the methods on it.
aiService: {
vi.mock('../../services/aiService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
return {
...actual,
// The singleton instance is named `aiService`. We mock the methods on it.
aiService: {
...actual.aiService, // Spread original methods in case new ones are added
extractItemsFromReceiptImage: vi.fn().mockResolvedValue([
{ raw_item_description: 'Mock Receipt Item', price_paid_cents: 100 },
]),
@@ -340,5 +360,6 @@ vi.mock('../../services/aiService.server', () => ({
text: 'Mocked trip plan.',
sources: [{ uri: 'http://maps.google.com/mock', title: 'Mock Map' }],
}),
},
}));
},
};
});