many fixes resulting from latest refactoring
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m6s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m6s
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
316
src/pages/admin/components/ProfileManager.Authenticated.test.tsx
Normal file
316
src/pages/admin/components/ProfileManager.Authenticated.test.tsx
Normal 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' }) }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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' }],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user