// src/App.test.tsx import React from 'react'; import { render, screen, waitFor, fireEvent, within, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import App from './App'; import * as aiApiClient from './services/aiApiClient'; // Import aiApiClient import * as apiClient from './services/apiClient'; import { AppProviders } from './providers/AppProviders'; import type { Flyer, UserProfile } from './types'; import { createMockFlyer, createMockUserProfile, createMockUser, } from './tests/utils/mockFactories'; import { mockUseAuth, mockUseFlyers, mockUseMasterItems, mockUseUserData, mockUseFlyerItems, } from './tests/setup/mockHooks'; import { useAppInitialization } from './hooks/useAppInitialization'; // Mock top-level components rendered by App's routes // Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM. // This must be done in any test file that imports App.tsx. vi.mock('pdfjs-dist', () => ({ // pdfjsLib: { GlobalWorkerOptions: { workerSrc: '' } }, GlobalWorkerOptions: { workerSrc: '' }, getDocument: vi.fn(() => ({ promise: Promise.resolve({ getPage: vi.fn() }), })), })); // Mock the new config module vi.mock('./config', () => ({ default: { app: { version: '20250101-1200:abc1234:1.0.0', commitMessage: 'Initial commit', commitUrl: '#' }, google: { mapsEmbedApiKey: 'mock-key' }, }, })); // Explicitly mock the hooks to ensure the component uses our spies vi.mock('./hooks/useFlyers', async () => { const hooks = await import('./tests/setup/mockHooks'); return { useFlyers: hooks.mockUseFlyers }; }); vi.mock('./hooks/useFlyerItems', async () => { const hooks = await import('./tests/setup/mockHooks'); return { useFlyerItems: hooks.mockUseFlyerItems }; }); vi.mock('./hooks/useAppInitialization'); const mockedUseAppInitialization = vi.mocked(useAppInitialization); vi.mock('./hooks/useAuth', async () => { const hooks = await import('./tests/setup/mockHooks'); return { useAuth: hooks.mockUseAuth }; }); vi.mock('./components/Footer', async () => { const { MockFooter } = await import('./tests/utils/componentMocks'); return { Footer: MockFooter }; }); vi.mock('./components/Header', async () => { const { MockHeader } = await import('./tests/utils/componentMocks'); return { Header: MockHeader }; }); vi.mock('./pages/HomePage', async () => { const { MockHomePage } = await import('./tests/utils/componentMocks'); return { HomePage: MockHomePage }; }); vi.mock('./pages/admin/AdminPage', async () => { const { MockAdminPage } = await import('./tests/utils/componentMocks'); return { AdminPage: MockAdminPage }; }); vi.mock('./pages/admin/CorrectionsPage', async () => { const { MockCorrectionsPage } = await import('./tests/utils/componentMocks'); return { CorrectionsPage: MockCorrectionsPage }; }); vi.mock('./pages/admin/AdminStatsPage', async () => { const { MockAdminStatsPage } = await import('./tests/utils/componentMocks'); return { AdminStatsPage: MockAdminStatsPage }; }); vi.mock('./pages/VoiceLabPage', async () => { const { MockVoiceLabPage } = await import('./tests/utils/componentMocks'); return { VoiceLabPage: MockVoiceLabPage }; }); vi.mock('./pages/ResetPasswordPage', async () => { const { MockResetPasswordPage } = await import('./tests/utils/componentMocks'); return { ResetPasswordPage: MockResetPasswordPage }; }); vi.mock('./pages/admin/components/ProfileManager', async () => { const { MockProfileManager } = await import('./tests/utils/componentMocks'); return { ProfileManager: MockProfileManager }; }); vi.mock('./features/voice-assistant/VoiceAssistant', async () => { const { MockVoiceAssistant } = await import('./tests/utils/componentMocks'); return { VoiceAssistant: MockVoiceAssistant }; }); vi.mock('./components/FlyerCorrectionTool', async () => { const { MockFlyerCorrectionTool } = await import('./tests/utils/componentMocks'); return { FlyerCorrectionTool: MockFlyerCorrectionTool }; }); vi.mock('./components/WhatsNewModal', async () => { const { MockWhatsNewModal } = await import('./tests/utils/componentMocks'); return { WhatsNewModal: MockWhatsNewModal }; }); vi.mock('./layouts/MainLayout', async () => { const { MockMainLayout } = await import('./tests/utils/componentMocks'); return { MainLayout: MockMainLayout }; }); vi.mock('./components/AppGuard', async () => { // We need to use the real useModal hook inside our mock AppGuard const { useModal } = await vi.importActual('./hooks/useModal'); return { AppGuard: ({ children }: { children: React.ReactNode }) => { const { isModalOpen } = useModal(); return (
{children} {isModalOpen('whatsNew') &&
}
); }, }; }); const mockedAiApiClient = vi.mocked(aiApiClient); const mockedApiClient = vi.mocked(apiClient); const mockFlyers: Flyer[] = [ createMockFlyer({ flyer_id: 1, store: { store_id: 1, name: 'Old Store' } }), createMockFlyer({ flyer_id: 2, store: { store_id: 2, name: 'Another Store' } }), ]; describe('App Component', () => { beforeEach(() => { console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults'); vi.clearAllMocks(); // Default auth state: loading or guest // Mock the login function to simulate a successful login. Signature: (token, profile) const mockLogin = vi.fn(async (_token: string, _profile?: UserProfile) => { await act(async () => { // Simulate fetching profile after login const profileResponse = await mockedApiClient.getAuthenticatedUserProfile(); const userProfileData: UserProfile = await profileResponse.json(); mockUseAuth.mockReturnValue({ userProfile: userProfileData, authStatus: 'AUTHENTICATED', isLoading: false, login: mockLogin, // Self-reference the mock logout: vi.fn(), updateProfile: vi.fn(), }); }); }); mockUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, // Start with isLoading: false for most tests login: mockLogin, logout: vi.fn(), updateProfile: vi.fn(), }); // Default data states for the new hooks mockUseFlyers.mockReturnValue({ flyers: mockFlyers, isLoadingFlyers: false, }); mockUseMasterItems.mockReturnValue({ masterItems: [], isLoading: false, }); mockUseUserData.mockReturnValue({ watchedItems: [], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), }); mockUseFlyerItems.mockReturnValue({ flyerItems: [], isLoading: false, error: null, }); mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' }); // Default mocks for API calls // Use mockImplementation to create a new Response object for each call, // preventing "Body has already been read" errors. // Use mockImplementation to create a new Response object for each call, // preventing "Body has already been read" errors. mockedApiClient.fetchFlyers.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))), ); // Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => Promise.resolve( new Response( JSON.stringify( createMockUserProfile({ user: { user_id: 'test-user-id', email: 'test@example.com' }, full_name: 'Test User', role: 'user', points: 0, }), ), ), ), ); mockedApiClient.fetchMasterItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))), ); mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))), ); mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))), ); mockedAiApiClient.rescanImageArea.mockResolvedValue( new Response(JSON.stringify({ text: 'mocked text' })), ); // Mock for FlyerCorrectionTool console.log('[TEST DEBUG] beforeEach: Setup complete'); }); const renderApp = (initialEntries = ['/']) => { return render( , ); }; it('should render the main layout and header', async () => { // Simulate the auth hook finishing its initial check mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' }); mockUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); renderApp(); await waitFor(() => { expect(screen.getByTestId('app-guard-mock')).toBeInTheDocument(); expect(screen.getByTestId('header-mock')).toBeInTheDocument(); // Check that the main layout and home page are rendered for the root path expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument(); expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); }); }); it('should render the footer', async () => { renderApp(); await waitFor(() => { // This test will pass because we added the mock for the Footer component expect(screen.getByTestId('footer-mock')).toBeInTheDocument(); expect(screen.getByText('Mock Footer')).toBeInTheDocument(); }); }); it('should render the BulkImporter for an admin user', async () => { console.log('[TEST DEBUG] Test Start: should render the BulkImporter for an admin user'); const mockAdminProfile: UserProfile = createMockUserProfile({ user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin', }); // Force the auth hook to return an authenticated admin user mockUseAuth.mockReturnValue({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); console.log('[TEST DEBUG] Rendering App with /admin route'); renderApp(['/admin']); await waitFor(() => { console.log('[TEST DEBUG] Waiting for admin-page-mock'); expect(screen.getByTestId('header-mock')).toBeInTheDocument(); expect(screen.getByTestId('admin-page-mock')).toBeInTheDocument(); }); }); it('should show a welcome message when no flyer is selected', async () => { // Simulate the auth hook finishing its initial check mockUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); renderApp(); await waitFor(() => { // The welcome message is inside HomePage, which is now mocked. We check for the mock instead. expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); }); }); it('should render the admin page on the /admin route', async () => { const mockAdminProfile: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'admin-id', email: 'admin@example.com' }), role: 'admin', }); // Directly set the auth state via the mocked hook mockUseAuth.mockReturnValue({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); console.log('[TEST DEBUG] Rendering App with /admin route'); renderApp(['/admin']); console.log('[TEST DEBUG] Waiting for admin-page-mock'); expect(await screen.findByTestId('admin-page-mock')).toBeInTheDocument(); }); it('should render the reset password page on the correct route', async () => { renderApp(['/reset-password/some-token']); await waitFor(() => { expect(screen.getByTestId('reset-password-page-mock')).toBeInTheDocument(); }); }); describe('Flyer Selection from URL', () => { it('should select a flyer when flyerId is present in the URL', async () => { renderApp(['/flyers/2']); // The HomePage mock will be rendered. The important part is that the selection logic // in App.tsx runs and passes the correct `selectedFlyer` prop down. // Since HomePage is mocked, we can't see the direct result, but we can // infer that the logic ran without crashing and the correct route was matched. await waitFor(() => { expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); }); }); it('should not select a flyer if the flyerId from the URL does not exist', async () => { // This test covers the `if (flyerToSelect)` branch in the useEffect. renderApp(['/flyers/999']); // 999 does not exist in mockFlyers await waitFor(() => { expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); }); // The main assertion is that no error is thrown. }); it('should select the first flyer if no flyer is selected and flyers are available', async () => { renderApp(['/']); await waitFor(() => { expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); }); }); }); describe('Modal Interactions', () => { it('should open and close the ProfileManager modal', async () => { console.log('[TEST DEBUG] Test Start: should open and close the ProfileManager modal'); renderApp(); expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); // Open modal fireEvent.click(screen.getByText('Open Profile')); expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument(); console.log('[TEST DEBUG] ProfileManager modal opened. Now closing...'); // Close modal fireEvent.click(screen.getByText('Close Profile')); await waitFor(() => { expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); }); console.log('[TEST DEBUG] ProfileManager modal closed.'); }); it('should open and close the VoiceAssistant modal for authenticated users', async () => { console.log('[TEST DEBUG] Test Start: should open and close the VoiceAssistant modal'); mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ role: 'user', user: { user_id: '1', email: 'test@test.com' }, }), authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); console.log('[TEST DEBUG] Rendering App'); renderApp(); expect(screen.queryByTestId('voice-assistant-mock')).not.toBeInTheDocument(); // Open modal console.log('[TEST DEBUG] Clicking Open Voice Assistant'); fireEvent.click(screen.getByText('Open Voice Assistant')); console.log('[TEST DEBUG] Waiting for voice-assistant-mock'); expect(await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 })).toBeInTheDocument(); // Close modal fireEvent.click(screen.getByText('Close Voice Assistant')); await waitFor(() => { expect(screen.queryByTestId('voice-assistant-mock')).not.toBeInTheDocument(); }); }); it('should not render the FlyerCorrectionTool if no flyer is selected', () => { mockUseFlyers.mockReturnValue({ flyers: [], isLoadingFlyers: false }); renderApp(); // Try to open the modal via the mocked HomePage button fireEvent.click(screen.getByText('Open Correction Tool')); expect(screen.queryByTestId('flyer-correction-tool-mock')).not.toBeInTheDocument(); }); it('should open and close the FlyerCorrectionTool modal', async () => { renderApp(); expect(screen.queryByTestId('flyer-correction-tool-mock')).not.toBeInTheDocument(); // Open modal fireEvent.click(screen.getByText('Open Correction Tool')); expect(await screen.findByTestId('flyer-correction-tool-mock')).toBeInTheDocument(); // Close modal fireEvent.click(screen.getByText('Close Correction')); await waitFor(() => { expect(screen.queryByTestId('flyer-correction-tool-mock')).not.toBeInTheDocument(); }); }); it('should render admin sub-routes correctly', async () => { console.log('[TEST DEBUG] Test Start: should render admin sub-routes correctly'); const mockAdminProfile: UserProfile = createMockUserProfile({ user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin', }); mockUseAuth.mockReturnValue({ userProfile: mockAdminProfile, authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); console.log( 'Testing admin sub-routes with renderApp wrapper to ensure ModalProvider context', ); console.log('[TEST DEBUG] Rendering App with /admin/corrections'); renderApp(['/admin/corrections']); await waitFor(() => { console.log('[TEST DEBUG] Waiting for corrections-page-mock'); expect(screen.getByTestId('corrections-page-mock')).toBeInTheDocument(); }); }); }); describe('Flyer Correction Tool Data Handling', () => { it('should handle store name extraction from the correction tool', async () => { // Ensure flyers are present so a flyer is auto-selected mockUseFlyers.mockReturnValue({ flyers: mockFlyers, isLoadingFlyers: false, }); renderApp(); fireEvent.click(screen.getByText('Open Correction Tool')); const correctionTool = await screen.findByTestId( 'flyer-correction-tool-mock', {}, { timeout: 2000 }, ); // We trigger the callback from the mock and ensure it doesn't crash. fireEvent.click(within(correctionTool).getByText('Extract Store')); // The test passes if no errors are thrown here. }); it('should handle date extraction from the correction tool', async () => { // Ensure flyers are present so a flyer is auto-selected mockUseFlyers.mockReturnValue({ flyers: mockFlyers, isLoadingFlyers: false, }); renderApp(); fireEvent.click(screen.getByText('Open Correction Tool')); const correctionTool = await screen.findByTestId( 'flyer-correction-tool-mock', {}, { timeout: 2000 }, ); fireEvent.click(within(correctionTool).getByText('Extract Dates')); // The test passes if no errors are thrown here, covering the 'dates' branch. }); }); describe('Profile and Login Handlers', () => { it('should call updateProfile when handleProfileUpdate is triggered', async () => { console.log( '[TEST DEBUG] Test Start: should call updateProfile when handleProfileUpdate is triggered', ); const mockUpdateProfile = vi.fn(); // To test profile updates, the user must be authenticated to see the "Update Profile" button. mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ user: { user_id: 'test-user', email: 'test@example.com' }, role: 'user', }), authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: mockUpdateProfile, }); console.log('[TEST DEBUG] Rendering App'); renderApp(); console.log('[TEST DEBUG] Opening Profile'); fireEvent.click(screen.getByText('Open Profile')); const profileManager = await screen.findByTestId('profile-manager-mock'); console.log('[TEST DEBUG] Clicking Update Profile'); fireEvent.click(within(profileManager).getByText('Update Profile')); await waitFor(() => { console.log('[TEST DEBUG] Checking mockUpdateProfile calls:', mockUpdateProfile.mock.calls); expect(mockUpdateProfile).toHaveBeenCalledWith( expect.objectContaining({ full_name: 'Updated' }), ); }); }); it('should set an error state if login fails inside handleLoginSuccess', async () => { console.log( '[TEST DEBUG] Test Start: should set an error state if login fails inside handleLoginSuccess', ); const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed')); mockUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: mockLogin, logout: vi.fn(), updateProfile: vi.fn(), }); // Mock the login function to simulate a successful login. Signature: (token, profile) const mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => { // Simulate fetching profile after login const profileResponse = await mockedApiClient.getAuthenticatedUserProfile(); const userProfileData: UserProfile = await profileResponse.json(); mockUseAuth.mockReturnValue({ ...mockUseAuth(), userProfile: userProfileData, authStatus: 'AUTHENTICATED' }); }); console.log('[TEST DEBUG] Rendering App'); renderApp(); console.log('[TEST DEBUG] Opening Profile'); fireEvent.click(screen.getByText('Open Profile')); const loginButton = await screen.findByRole('button', { name: 'Login' }); console.log('[TEST DEBUG] Clicking Login'); fireEvent.click(loginButton); // We need to wait for the async login function to be called and reject. await waitFor(() => { console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); expect(mockLogin).toHaveBeenCalled(); }); }); }); describe("Version Display and What's New", () => { beforeEach(() => { vi.mock('./config', () => ({ default: { app: { version: '2.0.0', commitMessage: 'A new version!', commitUrl: 'http://example.com/commit/2.0.0', }, }, })); }); it('should display the version number and commit link', () => { renderApp(); const versionLink = screen.getByText(`Version: 2.0.0`); expect(versionLink).toBeInTheDocument(); expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0'); }); it('should open the "What\'s New" modal when the question mark icon is clicked', async () => { renderApp(); const openButton = await screen.findByTitle("Show what's new in this version"); fireEvent.click(openButton); // The mock AppGuard now renders the modal when it's open expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument(); }); }); });