Files
flyer-crawler.projectium.com/src/App.test.tsx
2025-12-05 23:34:03 -08:00

172 lines
9.1 KiB
TypeScript

// src/App.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { MemoryRouter, Outlet } from 'react-router-dom';
import App from './App';
import * as apiClient from './services/apiClient';
// Mock useAuth to allow overriding the user state in tests
const mockUseAuth = vi.fn();
vi.mock('./hooks/useAuth', () => ({
useAuth: () => mockUseAuth(),
}));
// Mock child components to isolate the App component
vi.mock('./features/flyer/FlyerDisplay', () => ({ FlyerDisplay: () => <div data-testid="flyer-display-mock">Flyer Display</div> }));
vi.mock('./features/flyer/ExtractedDataTable', () => ({ ExtractedDataTable: () => <div data-testid="extracted-data-table-mock">Extracted Data Table</div> }));
vi.mock('./features/flyer/AnalysisPanel', () => ({ AnalysisPanel: () => <div data-testid="analysis-panel-mock">Analysis Panel</div> }));
vi.mock('./features/charts/PriceChart', () => ({ PriceChart: () => <div data-testid="price-chart-mock">Price Chart</div> }));
vi.mock('./components/Header', () => ({ Header: () => <header data-testid="header-mock">Header</header> }));
vi.mock('./features/flyer/BulkImporter', () => ({ BulkImporter: () => <div data-testid="bulk-importer-mock">Bulk Importer</div> }));
vi.mock('./features/charts/PriceHistoryChart', () => ({ PriceHistoryChart: () => <div data-testid="price-history-chart-mock">Price History Chart</div> }));
vi.mock('./features/flyer/FlyerList', () => ({ FlyerList: () => <div data-testid="flyer-list-mock">Flyer List</div> }));
vi.mock('./features/flyer/ProcessingStatus', () => ({ ProcessingStatus: () => <div data-testid="processing-status-mock">Processing Status</div> }));
vi.mock('./features/flyer/BulkImportSummary', () => ({ BulkImportSummary: () => <div data-testid="bulk-import-summary-mock">Bulk Import Summary</div> }));
vi.mock('./pages/admin/components/ProfileManager', () => ({ ProfileManager: () => <div data-testid="profile-manager-mock">Profile Manager</div> }));
vi.mock('./features/shopping/ShoppingList', () => ({ ShoppingListComponent: () => <div data-testid="shopping-list-mock">Shopping List</div> }));
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({ VoiceAssistant: () => <div data-testid="voice-assistant-mock">Voice Assistant</div> }));
vi.mock('./pages/admin/AdminPage', () => ({ AdminPage: () => <div data-testid="admin-page-mock">Admin Page</div> }));
// In react-router v6, wrapper routes must render an <Outlet /> for nested routes to appear.
// Our previous mock was trying to render `{children}`, which is incorrect for this pattern.
// This new mock correctly simulates the behavior of the actual AdminRoute component.
vi.mock('./components/AdminRoute', () => ({
AdminRoute: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="admin-route-mock">
{children}
<Outlet />
</div>
)
}));
vi.mock('./pages/admin/CorrectionsPage', () => ({ CorrectionsPage: () => <div data-testid="corrections-page-mock">Corrections Page</div> }));
vi.mock('./pages/admin/ActivityLog', () => ({ ActivityLog: () => <div data-testid="activity-log-mock">Activity Log</div> }));
vi.mock('./features/shopping/WatchedItemsList', () => ({ WatchedItemsList: () => <div data-testid="watched-items-list-mock">Watched Items List</div> }));
vi.mock('./pages/admin/AdminStatsPage', () => ({ AdminStatsPage: () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div> }));
vi.mock('./pages/ResetPasswordPage', () => ({ ResetPasswordPage: () => <div data-testid="reset-password-page-mock">Reset Password Page</div> }));
vi.mock('./pages/admin/components/AnonymousUserBanner', () => ({ AnonymousUserBanner: () => <div data-testid="anonymous-user-banner-mock">Anonymous User Banner</div> }));
vi.mock('./pages/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> }));
vi.mock('./components/WhatsNewModal', () => ({ WhatsNewModal: () => <div data-testid="whats-new-modal-mock">What's New Modal</div> }));
// 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', () => ({
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(() => ({
promise: Promise.resolve({ getPage: vi.fn() }),
})),
}));
// By casting the apiClient to `Mocked<typeof apiClient>`, we get type-safe access
// to Vitest's mock functions like `mockResolvedValue`.
// The `Mocked` type is imported directly from 'vitest' to avoid the namespace
// collision that occurs when using `vi.Mocked` with an imported `vi` object.
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
describe('App Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default auth state: loading or guest
mockUseAuth.mockReturnValue({
user: null,
profile: null,
authStatus: 'Determining...',
isLoading: true,
});
// Clear local storage to prevent auth state from leaking between tests.
localStorage.clear();
// 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([]))));
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([]))));
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Not authenticated'));
});
const renderApp = (initialEntries = ['/']) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
<App />
</MemoryRouter>
);
};
it('should render the main layout and header', async () => {
renderApp();
await waitFor(() => {
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
expect(screen.getByTestId('flyer-list-mock')).toBeInTheDocument();
// The BulkImporter is not rendered for anonymous users.
expect(screen.queryByTestId('bulk-importer-mock')).not.toBeInTheDocument();
});
});
it('should render the BulkImporter for an admin user', async () => {
// FIX 4: Update the BulkImporter test case to simulate an admin user via useAuth mock
const mockAdminProfile = {
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
};
// Force the auth hook to return an authenticated admin user
mockUseAuth.mockReturnValue({
user: mockAdminProfile.user,
profile: mockAdminProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
});
renderApp();
// Wait for the header (which means app loaded) AND the bulk importer
await waitFor(() => {
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
expect(screen.getByTestId('bulk-importer-mock')).toBeInTheDocument();
});
});
it('should show a welcome message when no flyer is selected', async () => {
renderApp();
await waitFor(() => {
expect(screen.getByText(/welcome to flyer crawler/i)).toBeInTheDocument();
});
});
it('should render the admin page on the /admin route', async () => {
// Mock a logged-in admin user
const mockAdminProfile = {
// The Profile type requires user_id at the top level, in addition
// to the nested user object.
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
};
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockAdminProfile)));
// The app's auth hook checks for a token before fetching the user profile.
// We need to mock it to allow the authentication flow to proceed.
localStorage.setItem('authToken', 'fake-admin-token');
renderApp(['/admin']);
// Use findByTestId to handle the asynchronous rendering of the page
// after the user profile has been fetched and the auth state has been updated.
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();
});
});
});