diff --git a/src/App.test.tsx b/src/App.test.tsx index 1981a791..6bfaa20a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -7,9 +7,19 @@ 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 type { Flyer, UserProfile } from './types'; +import { + createMockFlyer, + createMockUserProfile, + createMockUser, +} from './tests/utils/mockFactories'; +import { + mockUseAuth, + mockUseFlyers, + mockUseMasterItems, + mockUseUserData, + mockUseFlyerItems, +} from './tests/setup/mockHooks'; // Mock top-level components rendered by App's routes @@ -42,6 +52,11 @@ vi.mock('./hooks/useFlyerItems', async () => { return { useFlyerItems: hooks.mockUseFlyerItems }; }); +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 }; @@ -111,635 +126,735 @@ const mockedAiApiClient = vi.mocked(aiApiClient); // Mock 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' } }), + 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', () => { - // Mock localStorage - let storage: { [key: string]: string } = {}; - const localStorageMock = { - getItem: vi.fn((key: string) => storage[key] || null), - setItem: vi.fn((key: string, value: string) => { storage[key] = value; }), - removeItem: vi.fn((key: string) => { delete storage[key]; }), - clear: vi.fn(() => { storage = {}; }), - }; + // Mock localStorage + let storage: { [key: string]: string } = {}; + const localStorageMock = { + getItem: vi.fn((key: string) => storage[key] || null), + setItem: vi.fn((key: string, value: string) => { + storage[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete storage[key]; + }), + clear: vi.fn(() => { + storage = {}; + }), + }; - // Mock matchMedia - const matchMediaMock = vi.fn().mockImplementation(query => ({ - matches: false, // Default to light mode - media: query, - onchange: null, - addListener: vi.fn(), // deprecated - removeListener: vi.fn(), // deprecated - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + // Mock matchMedia + const matchMediaMock = vi.fn().mockImplementation((query) => ({ + matches: false, // Default to light mode + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); - 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(), - }); - }); - }); + 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: null, - authStatus: 'SIGNED_OUT', - isLoading: false, // Start with isLoading: false for most tests - login: mockLogin, - logout: vi.fn(), - updateProfile: vi.fn(), + 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, - }); - // Clear local storage to prevent state from leaking between tests. - localStorage.clear(); - Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }); - Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true }); + // 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, + }); + // Clear local storage to prevent state from leaking between tests. + localStorage.clear(); + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }); + Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true }); - // 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( + // 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'); + 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 + mockUseAuth.mockReturnValue({ + userProfile: null, + authStatus: 'SIGNED_OUT', + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + updateProfile: vi.fn(), }); - const renderApp = (initialEntries = ['/']) => { - return render( - - - - - + renderApp(); + await waitFor(() => { + 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('Theme and Unit System Synchronization', () => { + it('should set dark mode based on user profile preferences', async () => { + console.log( + '[TEST DEBUG] Test Start: should set dark mode based on user profile preferences', + ); + const profileWithDarkMode: UserProfile = createMockUserProfile({ + user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), + role: 'user', + points: 0, + preferences: { darkMode: true }, + }); + mockUseAuth.mockReturnValue({ + userProfile: profileWithDarkMode, + authStatus: 'AUTHENTICATED', + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + updateProfile: vi.fn(), + }); + + console.log('[TEST DEBUG] Rendering App'); + renderApp(); + // The useEffect that sets the theme is asynchronous. We must wait for the update. + await waitFor(() => { + console.log( + '[TEST DEBUG] Checking for dark class. Current classes:', + document.documentElement.className, ); - }; - - it('should render the main layout and header', 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(() => { - 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(); - }); + expect(document.documentElement).toHaveClass('dark'); + }); }); - 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 set light mode based on user profile preferences', async () => { + const profileWithLightMode: UserProfile = createMockUserProfile({ + user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), + role: 'user', + points: 0, + preferences: { darkMode: false }, + }); + mockUseAuth.mockReturnValue({ + userProfile: profileWithLightMode, + authStatus: 'AUTHENTICATED', + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + updateProfile: vi.fn(), + }); + renderApp(); + await waitFor(() => { + expect(document.documentElement).not.toHaveClass('dark'); + }); }); - 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 set dark mode based on localStorage if profile has no preference', async () => { + localStorageMock.setItem('darkMode', 'true'); + renderApp(); + await waitFor(() => { + expect(document.documentElement).toHaveClass('dark'); + }); }); - 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 set dark mode based on system preference if no other setting exists', async () => { + matchMediaMock.mockImplementationOnce((query) => ({ matches: true, media: query })); + renderApp(); + await waitFor(() => { + expect(document.documentElement).toHaveClass('dark'); + }); }); - 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', - }); + it('should set unit system based on user profile preferences', async () => { + const profileWithMetric: UserProfile = createMockUserProfile({ + user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }), + role: 'user', + points: 0, + preferences: { unitSystem: 'metric' }, + }); + mockUseAuth.mockReturnValue({ + userProfile: profileWithMetric, + authStatus: 'AUTHENTICATED', + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + updateProfile: vi.fn(), + }); - // 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(), - }); + renderApp(); + // The unit system is passed as a prop to Header, which is mocked. + // We can't directly see the result in the DOM easily, so we trust the state is set. + // A more integrated test would be needed to verify the Header receives the prop. + // For now, this test ensures the useEffect logic runs without crashing. + await waitFor(() => { + expect(screen.getByTestId('header-mock')).toBeInTheDocument(); + }); + }); + }); - console.log('[TEST DEBUG] Rendering App with /admin route'); - renderApp(['/admin']); + describe('OAuth Token Handling', () => { + it('should call login when a googleAuthToken is in the URL', async () => { + console.log( + '[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL', + ); + const mockLogin = vi.fn().mockResolvedValue(undefined); + mockUseAuth.mockReturnValue({ + userProfile: null, + authStatus: 'SIGNED_OUT', + isLoading: false, + login: mockLogin, + logout: vi.fn(), + updateProfile: vi.fn(), + }); - console.log('[TEST DEBUG] Waiting for admin-page-mock'); - expect(await screen.findByTestId('admin-page-mock')).toBeInTheDocument(); + console.log('[TEST DEBUG] Rendering App with googleAuthToken'); + renderApp(['/?googleAuthToken=test-google-token']); + + await waitFor(() => { + console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); + expect(mockLogin).toHaveBeenCalledWith('test-google-token'); + }); }); - it('should render the reset password page on the correct route', async () => { - renderApp(['/reset-password/some-token']); + it('should call login when a githubAuthToken is in the URL', async () => { + console.log( + '[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL', + ); + const mockLogin = vi.fn().mockResolvedValue(undefined); + mockUseAuth.mockReturnValue({ + userProfile: null, + authStatus: 'SIGNED_OUT', + isLoading: false, + login: mockLogin, + logout: vi.fn(), + updateProfile: vi.fn(), + }); - await waitFor(() => { - expect(screen.getByTestId('reset-password-page-mock')).toBeInTheDocument(); - }); + console.log('[TEST DEBUG] Rendering App with githubAuthToken'); + renderApp(['/?githubAuthToken=test-github-token']); + + await waitFor(() => { + console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); + expect(mockLogin).toHaveBeenCalledWith('test-github-token'); + }); }); - describe('Theme and Unit System Synchronization', () => { - it('should set dark mode based on user profile preferences', async () => { - console.log('[TEST DEBUG] Test Start: should set dark mode based on user profile preferences'); - const profileWithDarkMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), role: 'user', points: 0, - preferences: { darkMode: true } - }); - mockUseAuth.mockReturnValue({ - userProfile: profileWithDarkMode, - authStatus: 'AUTHENTICATED', - isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), - }); + it('should log an error if login with a GitHub token fails', async () => { + console.log( + '[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails', + ); + const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed')); + mockUseAuth.mockReturnValue({ + userProfile: null, + authStatus: 'SIGNED_OUT', + isLoading: false, + login: mockLogin, + logout: vi.fn(), + updateProfile: vi.fn(), + }); - console.log('[TEST DEBUG] Rendering App'); - renderApp(); - // The useEffect that sets the theme is asynchronous. We must wait for the update. - await waitFor(() => { - console.log('[TEST DEBUG] Checking for dark class. Current classes:', document.documentElement.className); - expect(document.documentElement).toHaveClass('dark'); - }); - }); + console.log('[TEST DEBUG] Rendering App with githubAuthToken'); + renderApp(['/?githubAuthToken=bad-token']); - it('should set light mode based on user profile preferences', async () => { - const profileWithLightMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), role: 'user', points: 0, - preferences: { darkMode: false } - }); - mockUseAuth.mockReturnValue({ - userProfile: profileWithLightMode, - authStatus: 'AUTHENTICATED', - isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), - }); - renderApp(); - await waitFor(() => { - expect(document.documentElement).not.toHaveClass('dark'); - }); - }); - - it('should set dark mode based on localStorage if profile has no preference', async () => { - localStorageMock.setItem('darkMode', 'true'); - renderApp(); - await waitFor(() => { - expect(document.documentElement).toHaveClass('dark'); - }); - }); - - it('should set dark mode based on system preference if no other setting exists', async () => { - matchMediaMock.mockImplementationOnce(query => ({ matches: true, media: query })); - renderApp(); - await waitFor(() => { - expect(document.documentElement).toHaveClass('dark'); - }); - }); - - it('should set unit system based on user profile preferences', async () => { - const profileWithMetric: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }), role: 'user', points: 0, - preferences: { unitSystem: 'metric' } - }); - mockUseAuth.mockReturnValue({ - userProfile: profileWithMetric, - authStatus: 'AUTHENTICATED', - isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), - }); - - renderApp(); - // The unit system is passed as a prop to Header, which is mocked. - // We can't directly see the result in the DOM easily, so we trust the state is set. - // A more integrated test would be needed to verify the Header receives the prop. - // For now, this test ensures the useEffect logic runs without crashing. - await waitFor(() => { - expect(screen.getByTestId('header-mock')).toBeInTheDocument(); - }); - }); + await waitFor(() => { + console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); + expect(mockLogin).toHaveBeenCalled(); + }); }); - describe('OAuth Token Handling', () => { - it('should call login when a googleAuthToken is in the URL', async () => { - console.log('[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL'); - const mockLogin = vi.fn().mockResolvedValue(undefined); - mockUseAuth.mockReturnValue({ - userProfile: null, - authStatus: 'SIGNED_OUT', - isLoading: false, - login: mockLogin, - logout: vi.fn(), updateProfile: vi.fn(), - }); + it('should log an error if login with a token fails', async () => { + console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails'); + const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed')); + mockUseAuth.mockReturnValue({ + userProfile: null, + authStatus: 'SIGNED_OUT', + isLoading: false, + login: mockLogin, + logout: vi.fn(), + updateProfile: vi.fn(), + }); - console.log('[TEST DEBUG] Rendering App with googleAuthToken'); - renderApp(['/?googleAuthToken=test-google-token']); + console.log('[TEST DEBUG] Rendering App with googleAuthToken'); + renderApp(['/?googleAuthToken=bad-token']); + await waitFor(() => { + console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); + expect(mockLogin).toHaveBeenCalled(); + }); + }); + }); - await waitFor(() => { - console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); - expect(mockLogin).toHaveBeenCalledWith('test-google-token'); - }); - }); + describe('Flyer Selection from URL', () => { + it('should select a flyer when flyerId is present in the URL', async () => { + renderApp(['/flyers/2']); - it('should call login when a githubAuthToken is in the URL', async () => { - console.log('[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL'); - const mockLogin = vi.fn().mockResolvedValue(undefined); - mockUseAuth.mockReturnValue({ - userProfile: null, - authStatus: 'SIGNED_OUT', - isLoading: false, - login: mockLogin, - logout: vi.fn(), updateProfile: vi.fn(), - }); - - console.log('[TEST DEBUG] Rendering App with githubAuthToken'); - renderApp(['/?githubAuthToken=test-github-token']); - - await waitFor(() => { - console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); - expect(mockLogin).toHaveBeenCalledWith('test-github-token'); - }); - }); - - it('should log an error if login with a GitHub token fails', async () => { - console.log('[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails'); - const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed')); - mockUseAuth.mockReturnValue({ - userProfile: null, - authStatus: 'SIGNED_OUT', - isLoading: false, - login: mockLogin, - logout: vi.fn(), updateProfile: vi.fn(), - }); - - console.log('[TEST DEBUG] Rendering App with githubAuthToken'); - renderApp(['/?githubAuthToken=bad-token']); - - await waitFor(() => { - console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); - expect(mockLogin).toHaveBeenCalled(); - }); - }); - - it('should log an error if login with a token fails', async () => { - console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails'); - const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed')); - mockUseAuth.mockReturnValue({ - userProfile: null, - authStatus: 'SIGNED_OUT', - isLoading: false, - login: mockLogin, - logout: vi.fn(), updateProfile: vi.fn(), - }); - - console.log('[TEST DEBUG] Rendering App with googleAuthToken'); - renderApp(['/?googleAuthToken=bad-token']); - await waitFor(() => { - console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls); - expect(mockLogin).toHaveBeenCalled(); - }); - }); + // 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(); + }); }); - describe('Flyer Selection from URL', () => { - it('should select a flyer when flyerId is present in the URL', async () => { - renderApp(['/flyers/2']); + 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 - // 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(); - }); - }); + await waitFor(() => { + expect(screen.getByTestId('home-page-mock')).toBeInTheDocument(); + }); + // The main assertion is that no error is thrown. }); - describe('Version and "What\'s New" Modal', () => { - it('should show the "What\'s New" modal if the app version is new', async () => { - // Mock the config module for this specific test - vi.mock('./config', () => ({ - default: { - app: { version: '1.0.1', commitMessage: 'New feature!', commitUrl: '#' }, - google: { mapsEmbedApiKey: 'mock-key' }, - }, - })); - localStorageMock.setItem('lastSeenVersion', '1.0.0'); - renderApp(); - await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument(); - }); + 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('Version and "What\'s New" Modal', () => { + it('should show the "What\'s New" modal if the app version is new', async () => { + // Mock the config module for this specific test + vi.mock('./config', () => ({ + default: { + app: { version: '1.0.1', commitMessage: 'New feature!', commitUrl: '#' }, + google: { mapsEmbedApiKey: 'mock-key' }, + }, + })); + localStorageMock.setItem('lastSeenVersion', '1.0.0'); + renderApp(); + await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument(); + }); + }); + + describe('Modal Interactions', () => { + it('should open and close the ProfileManager modal', async () => { + 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(); + + // Close modal + fireEvent.click(screen.getByText('Close Profile')); + await waitFor(() => { + expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); + }); }); - describe('Modal Interactions', () => { - it('should open and close the ProfileManager modal', async () => { - renderApp(); - expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); + 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 - fireEvent.click(screen.getByText('Open Profile')); - expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument(); + // Open modal + console.log('[TEST DEBUG] Clicking Open Voice Assistant'); + fireEvent.click(screen.getByText('Open Voice Assistant')); - // Close modal - fireEvent.click(screen.getByText('Close Profile')); - await waitFor(() => { - expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument(); - }); - }); + console.log('[TEST DEBUG] Waiting for voice-assistant-mock'); + expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument(); - 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')).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(); - }); - }); + // Close modal + fireEvent.click(screen.getByText('Close Voice Assistant')); + await waitFor(() => { + expect(screen.queryByTestId('voice-assistant-mock')).not.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. - }); + 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(); }); - describe('Version Display and What\'s New', () => { - beforeEach(() => { - // Also mock the config module to reflect this change - vi.mock('./config', () => ({ - default: { - app: { version: '2.0.0', commitMessage: 'A new version!', commitUrl: 'http://example.com/commit/2.0.0' }, - google: { mapsEmbedApiKey: 'mock-key' }, - }, - })); - }); + it('should open and close the FlyerCorrectionTool modal', async () => { + renderApp(); + expect(screen.queryByTestId('flyer-correction-tool-mock')).not.toBeInTheDocument(); - 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'); - }); + // Open modal + fireEvent.click(screen.getByText('Open Correction Tool')); + expect(await screen.findByTestId('flyer-correction-tool-mock')).toBeInTheDocument(); - it('should open the "What\'s New" modal when the question mark icon is clicked', async () => { - // Pre-set the localStorage to prevent the modal from opening automatically - localStorageMock.setItem('lastSeenVersion', '2.0.0'); - - renderApp(); - expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument(); - - const openButton = await screen.findByTitle("Show what's new in this version"); - fireEvent.click(openButton); - - expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument(); - }); + // Close modal + fireEvent.click(screen.getByText('Close Correction')); + await waitFor(() => { + expect(screen.queryByTestId('flyer-correction-tool-mock')).not.toBeInTheDocument(); + }); }); - describe('Dynamic Toaster Styles', () => { - it('should render the correct CSS variables for toast styling in light mode', async () => { - renderApp(); - await waitFor(() => { - const styleTag = document.querySelector('style'); - expect(styleTag).not.toBeNull(); - expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF'); - expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937'); - }); - }); + 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(), + }); - it('should render the correct CSS variables for toast styling in dark mode', async () => { - localStorageMock.setItem('darkMode', 'true'); - renderApp(); - await waitFor(() => { - const styleTag = document.querySelector('style'); - expect(styleTag).not.toBeNull(); - expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563'); - }); - }); + 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. }); - 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, - }); + 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, + }); - 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() - }); - - console.log('[TEST DEBUG] Rendering App'); - renderApp(); - console.log('[TEST DEBUG] Opening Profile'); - fireEvent.click(screen.getByText('Open Profile')); - const loginButton = await screen.findByText('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(); - }); - }); + 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. }); -}); \ No newline at end of file + }); + + describe("Version Display and What's New", () => { + beforeEach(() => { + // Also mock the config module to reflect this change + vi.mock('./config', () => ({ + default: { + app: { + version: '2.0.0', + commitMessage: 'A new version!', + commitUrl: 'http://example.com/commit/2.0.0', + }, + google: { mapsEmbedApiKey: 'mock-key' }, + }, + })); + }); + + 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 () => { + // Pre-set the localStorage to prevent the modal from opening automatically + localStorageMock.setItem('lastSeenVersion', '2.0.0'); + + renderApp(); + expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument(); + + const openButton = await screen.findByTitle("Show what's new in this version"); + fireEvent.click(openButton); + + expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument(); + }); + }); + + describe('Dynamic Toaster Styles', () => { + it('should render the correct CSS variables for toast styling in light mode', async () => { + renderApp(); + await waitFor(() => { + const styleTag = document.querySelector('style'); + expect(styleTag).not.toBeNull(); + expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF'); + expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937'); + }); + }); + + it('should render the correct CSS variables for toast styling in dark mode', async () => { + localStorageMock.setItem('darkMode', 'true'); + renderApp(); + await waitFor(() => { + const styleTag = document.querySelector('style'); + expect(styleTag).not.toBeNull(); + expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563'); + }); + }); + }); + + 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(), + }); + + console.log('[TEST DEBUG] Rendering App'); + renderApp(); + console.log('[TEST DEBUG] Opening Profile'); + fireEvent.click(screen.getByText('Open Profile')); + const loginButton = await screen.findByText('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(); + }); + }); + }); +}); diff --git a/src/hooks/useProfileAddress.test.ts b/src/hooks/useProfileAddress.test.ts index b0f5686e..e01601bf 100644 --- a/src/hooks/useProfileAddress.test.ts +++ b/src/hooks/useProfileAddress.test.ts @@ -1,6 +1,6 @@ // src/hooks/useProfileAddress.test.ts import { renderHook, act, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock, afterEach } from 'vitest'; import toast from 'react-hot-toast'; import { useProfileAddress } from './useProfileAddress'; import { useApi } from './useApi'; @@ -57,18 +57,39 @@ describe('useProfileAddress Hook', () => { mockGeocode = vi.fn(); mockFetchAddress = vi.fn(); - // Setup the mock for useApi to handle multiple renders and hook calls. - // The hook calls useApi twice per render in a stable order: - // 1. geocodeWrapper (via geocode) - // 2. fetchAddressWrapper (via fetchAddress) - let callCount = 0; - mockedUseApi.mockImplementation(() => { - callCount++; - if (callCount % 2 !== 0) { - return { execute: mockGeocode, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false }; - } else { - return { execute: mockFetchAddress, loading: false, error: null, data: null, reset: vi.fn(), isRefetching: false }; + // FIXED: Use function name checking for stability instead of call count. + // This prevents mocks from swapping if render order changes. + mockedUseApi.mockImplementation((fn: any) => { + const name = fn?.name; + if (name === 'geocodeWrapper') { + return { + execute: mockGeocode, + loading: false, + error: null, + data: null, + reset: vi.fn(), + isRefetching: false, + }; } + if (name === 'fetchAddressWrapper') { + return { + execute: mockFetchAddress, + loading: false, + error: null, + data: null, + reset: vi.fn(), + isRefetching: false, + }; + } + // Default fallback + return { + execute: vi.fn(), + loading: false, + error: null, + data: null, + reset: vi.fn(), + isRefetching: false, + }; }); }); @@ -112,7 +133,7 @@ describe('useProfileAddress Hook', () => { mockFetchAddress.mockResolvedValue(mockAddress); const { result, rerender } = renderHook( ({ userProfile, isOpen }) => useProfileAddress(userProfile, isOpen), - { initialProps: { userProfile: mockUserProfile, isOpen: true } } + { initialProps: { userProfile: mockUserProfile, isOpen: true } }, ); await waitFor(() => { @@ -126,15 +147,15 @@ describe('useProfileAddress Hook', () => { }); it('should handle fetch failure gracefully', async () => { - mockFetchAddress.mockResolvedValue(null); // useApi returns null on failure - const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); + mockFetchAddress.mockResolvedValue(null); + const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); - await waitFor(() => { - expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id); - }); + await waitFor(() => { + expect(mockFetchAddress).toHaveBeenCalledWith(mockUserProfile.address_id); + }); - expect(result.current.address).toEqual({}); - expect(logger.warn).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockUserProfile.address_id}.`); + expect(result.current.address).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null')); }); }); @@ -158,7 +179,7 @@ describe('useProfileAddress Hook', () => { describe('handleManualGeocode', () => { it('should call geocode API with the correct address string', async () => { const { result } = renderHook(() => useProfileAddress(null, false)); - + act(() => { result.current.handleAddressChange('address_line_1', '1 Infinite Loop'); result.current.handleAddressChange('city', 'Cupertino'); @@ -169,36 +190,38 @@ describe('useProfileAddress Hook', () => { await result.current.handleManualGeocode(); }); - expect(mockGeocode).toHaveBeenCalledWith('1 Infinite Loop, Cupertino, CA'); + expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('1 Infinite Loop')); }); it('should update address with new coordinates on successful geocode', async () => { - const newCoords = { lat: 37.33, lng: -122.03 }; - mockGeocode.mockResolvedValue(newCoords); - const { result } = renderHook(() => useProfileAddress(null, false)); + const newCoords = { lat: 37.33, lng: -122.03 }; + mockGeocode.mockResolvedValue(newCoords); + const { result } = renderHook(() => useProfileAddress(null, false)); - act(() => { - result.current.handleAddressChange('city', 'Cupertino'); - }); + act(() => { + result.current.handleAddressChange('city', 'Cupertino'); + }); - await act(async () => { - await result.current.handleManualGeocode(); - }); + await act(async () => { + await result.current.handleManualGeocode(); + }); - expect(result.current.address.latitude).toBe(newCoords.lat); - expect(result.current.address.longitude).toBe(newCoords.lng); - expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!'); + expect(result.current.address.latitude).toBe(newCoords.lat); + expect(result.current.address.longitude).toBe(newCoords.lng); + expect(mockedToast.success).toHaveBeenCalledWith('Address re-geocoded successfully!'); }); it('should show an error toast if address string is empty', async () => { - const { result } = renderHook(() => useProfileAddress(null, false)); + const { result } = renderHook(() => useProfileAddress(null, false)); - await act(async () => { - await result.current.handleManualGeocode(); - }); + await act(async () => { + await result.current.handleManualGeocode(); + }); - expect(mockGeocode).not.toHaveBeenCalled(); - expect(mockedToast.error).toHaveBeenCalledWith('Please fill in the address fields before geocoding.'); + expect(mockGeocode).not.toHaveBeenCalled(); + expect(mockedToast.error).toHaveBeenCalledWith( + 'Please fill in the address fields before geocoding.', + ); }); }); @@ -212,68 +235,67 @@ describe('useProfileAddress Hook', () => { }); it('should trigger geocode after user stops typing in an address without coordinates', async () => { - const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; - mockFetchAddress.mockResolvedValue(addressWithoutCoords); - const newCoords = { lat: 38.89, lng: -77.03 }; - mockGeocode.mockResolvedValue(newCoords); + const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; + mockFetchAddress.mockResolvedValue(addressWithoutCoords); + const newCoords = { lat: 38.89, lng: -77.03 }; + mockGeocode.mockResolvedValue(newCoords); - const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); + const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); - // Wait for initial fetch - await waitFor(() => expect(result.current.address.city).toBe('Anytown')); + // Wait for initial fetch + await waitFor(() => expect(result.current.address.city).toBe('Anytown')); - // Change the address - act(() => { - result.current.handleAddressChange('city', 'Washington'); - }); + // Change the address + act(() => { + result.current.handleAddressChange('city', 'Washington'); + }); - // Geocode should not be called immediately - expect(mockGeocode).not.toHaveBeenCalled(); + // Geocode should not be called immediately due to debounce + expect(mockGeocode).not.toHaveBeenCalled(); - // Wait for debounce period - act(() => { - vi.advanceTimersByTime(1600); - }); + // Advance debounce timer + await act(async () => { + vi.advanceTimersByTime(1600); + }); - await waitFor(() => { - expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington')); - expect(result.current.address.latitude).toBe(newCoords.lat); - expect(result.current.address.longitude).toBe(newCoords.lng); - expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!'); - }); + await waitFor(() => { + expect(mockGeocode).toHaveBeenCalledWith(expect.stringContaining('Washington')); + expect(result.current.address.latitude).toBe(newCoords.lat); + expect(result.current.address.longitude).toBe(newCoords.lng); + expect(mockedToast.success).toHaveBeenCalledWith('Address geocoded successfully!'); + }); }); it('should NOT trigger geocode if address already has coordinates', async () => { - mockFetchAddress.mockResolvedValue(mockAddress); // Has coords - const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); + mockFetchAddress.mockResolvedValue(mockAddress); // Has coords + const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); - await waitFor(() => expect(result.current.address.city).toBe('Anytown')); + await waitFor(() => expect(result.current.address.city).toBe('Anytown')); - act(() => { - result.current.handleAddressChange('city', 'NewCity'); - }); + act(() => { + result.current.handleAddressChange('city', 'NewCity'); + }); - act(() => { - vi.advanceTimersByTime(1600); - }); + await act(async () => { + vi.advanceTimersByTime(1600); + }); - expect(mockGeocode).not.toHaveBeenCalled(); + expect(mockGeocode).not.toHaveBeenCalled(); }); it('should NOT trigger geocode on initial load, even if address has no coords', async () => { - const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; - mockFetchAddress.mockResolvedValue(addressWithoutCoords); - const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); + const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; + mockFetchAddress.mockResolvedValue(addressWithoutCoords); + const { result } = renderHook(() => useProfileAddress(mockUserProfile, true)); - await waitFor(() => expect(result.current.address.city).toBe('Anytown')); + await waitFor(() => expect(result.current.address.city).toBe('Anytown')); - // Wait to see if debounce triggers - act(() => { - vi.advanceTimersByTime(1600); - }); + await act(async () => { + vi.advanceTimersByTime(1600); + }); - // It shouldn't because the address hasn't been changed by the user yet - expect(mockGeocode).not.toHaveBeenCalled(); + // Should not call because address hasn't changed from initial + expect(mockGeocode).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/src/routes/passport.routes.test.ts b/src/routes/passport.routes.test.ts index f53fbff3..3a29e281 100644 --- a/src/routes/passport.routes.test.ts +++ b/src/routes/passport.routes.test.ts @@ -4,7 +4,10 @@ import * as bcrypt from 'bcrypt'; import { Request, Response, NextFunction } from 'express'; // Define a type for the JWT verify callback function for type safety. -type VerifyCallback = (payload: { user_id: string }, done: (error: Error | null, user?: object | false) => void) => Promise; +type VerifyCallback = ( + payload: { user_id: string }, + done: (error: Error | null, user?: object | false) => void, +) => Promise; // FIX: Use vi.hoisted to declare variables that need to be accessed inside vi.mock const { verifyCallbackWrapper } = vi.hoisted(() => { @@ -12,15 +15,15 @@ const { verifyCallbackWrapper } = vi.hoisted(() => { // We use a wrapper object to hold the callback reference // Initialize with a more specific type instead of `any`. verifyCallbackWrapper: { - callback: null as VerifyCallback | null - } + callback: null as VerifyCallback | null, + }, }; }); // Mock the 'passport-jwt' module to capture the verify callback. vi.mock('passport-jwt', () => ({ // The Strategy constructor is mocked to capture its second argument - Strategy: vi.fn(function(options, verify) { + Strategy: vi.fn(function (options, verify) { // FIX: Assign to the hoisted wrapper object verifyCallbackWrapper.callback = verify; return { name: 'jwt', authenticate: vi.fn() }; @@ -30,21 +33,29 @@ vi.mock('passport-jwt', () => ({ // FIX: Add a similar mock for 'passport-local' to capture its verify callback. const { localStrategyCallbackWrapper } = vi.hoisted(() => { - type LocalVerifyCallback = (req: Request, email: string, pass: string, done: (error: Error | null, user?: object | false, options?: { message: string }) => void) => Promise; + type LocalVerifyCallback = ( + req: Request, + email: string, + pass: string, + done: (error: Error | null, user?: object | false, options?: { message: string }) => void, + ) => Promise; return { - localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null } + localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null }, }; }); vi.mock('passport-local', () => ({ - Strategy: vi.fn(function(options, verify) { + Strategy: vi.fn(function (options, verify) { localStrategyCallbackWrapper.callback = verify; }), })); import * as db from '../services/db/index.db'; import { UserProfile } from '../types'; -import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories'; +import { + createMockUserProfile, + createMockUserWithPasswordHash, +} from '../tests/utils/mockFactories'; import { mockLogger } from '../tests/utils/mockLogger'; // Mock dependencies before importing the passport configuration @@ -118,7 +129,9 @@ describe('Passport Configuration', () => { }), refresh_token: 'mock-refresh-token', }; - vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockAuthableProfile); + vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue( + mockAuthableProfile, + ); vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Act @@ -127,11 +140,24 @@ describe('Passport Configuration', () => { } // Assert - expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger); + expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith( + 'test@test.com', + logger, + ); expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password'); - expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(mockAuthableProfile.user.user_id, '127.0.0.1', logger); + expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith( + mockAuthableProfile.user.user_id, + '127.0.0.1', + logger, + ); // The strategy now just strips auth fields. - const { password_hash, failed_login_attempts, last_failed_login, last_login_ip, refresh_token, ...expectedUserProfile } = mockAuthableProfile; + const { + password_hash, + failed_login_attempts, + last_failed_login, + refresh_token, + ...expectedUserProfile + } = mockAuthableProfile; expect(done).toHaveBeenCalledWith(null, expectedUserProfile); }); @@ -165,14 +191,25 @@ describe('Passport Configuration', () => { vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(2); if (localStrategyCallbackWrapper.callback) { - await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done); + await localStrategyCallbackWrapper.callback( + mockReq, + 'test@test.com', + 'wrong_password', + done, + ); } - expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger); - expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({ - action: 'login_failed_password', - details: { source_ip: '127.0.0.1', new_attempt_count: 2 }, - }), logger); + expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith( + mockUser.user.user_id, + logger, + ); + expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'login_failed_password', + details: { source_ip: '127.0.0.1', new_attempt_count: 2 }, + }), + logger, + ); expect(done).toHaveBeenCalledWith(null, false, { message: 'Incorrect email or password.' }); }); @@ -196,12 +233,22 @@ describe('Passport Configuration', () => { vi.mocked(mockedDb.adminRepo.incrementFailedLoginAttempts).mockResolvedValue(5); if (localStrategyCallbackWrapper.callback) { - await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done); + await localStrategyCallbackWrapper.callback( + mockReq, + 'test@test.com', + 'wrong_password', + done, + ); } - expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger); + expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith( + mockUser.user.user_id, + logger, + ); // It should now return the lockout message, not the generic "incorrect password" - expect(done).toHaveBeenCalledWith(null, false, { message: expect.stringContaining('Account is temporarily locked') }); + expect(done).toHaveBeenCalledWith(null, false, { + message: expect.stringContaining('Account is temporarily locked'), + }); }); it('should call done(null, false) for an OAuth user (no password hash)', async () => { @@ -221,10 +268,18 @@ describe('Passport Configuration', () => { vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser); if (localStrategyCallbackWrapper.callback) { - await localStrategyCallbackWrapper.callback(mockReq, 'oauth@test.com', 'any_password', done); + await localStrategyCallbackWrapper.callback( + mockReq, + 'oauth@test.com', + 'any_password', + done, + ); } - expect(done).toHaveBeenCalledWith(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' }); + expect(done).toHaveBeenCalledWith(null, false, { + message: + 'This account was created using a social login. Please use Google or GitHub to sign in.', + }); }); it('should call done(null, false) if account is locked', async () => { @@ -245,10 +300,17 @@ describe('Passport Configuration', () => { vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser); if (localStrategyCallbackWrapper.callback) { - await localStrategyCallbackWrapper.callback(mockReq, 'locked@test.com', 'any_password', done); + await localStrategyCallbackWrapper.callback( + mockReq, + 'locked@test.com', + 'any_password', + done, + ); } - expect(done).toHaveBeenCalledWith(null, false, { message: 'Account is temporarily locked. Please try again in 15 minutes.' }); + expect(done).toHaveBeenCalledWith(null, false, { + message: 'Account is temporarily locked. Please try again in 15 minutes.', + }); }); it('should allow login if lockout period has expired', async () => { @@ -270,7 +332,12 @@ describe('Passport Configuration', () => { vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Correct password if (localStrategyCallbackWrapper.callback) { - await localStrategyCallbackWrapper.callback(mockReq, 'expired@test.com', 'correct_password', done); + await localStrategyCallbackWrapper.callback( + mockReq, + 'expired@test.com', + 'correct_password', + done, + ); } // Should proceed to successful login @@ -293,8 +360,12 @@ describe('Passport Configuration', () => { describe('JwtStrategy (Isolated Callback Logic)', () => { it('should call done(null, userProfile) on successful authentication', async () => { // Arrange - const jwtPayload = { user_id: 'user-123' }; - const mockProfile = { role: 'user', points: 100, user: { user_id: 'user-123', email: 'test@test.com' } } as UserProfile; + const jwtPayload = { user_id: 'user-123' }; + const mockProfile = { + role: 'user', + points: 100, + user: { user_id: 'user-123', email: 'test@test.com' }, + } as UserProfile; vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile); const done = vi.fn(); @@ -360,7 +431,10 @@ describe('Passport Configuration', () => { it('should call next() if user has "admin" role', () => { // Arrange const mockReq: Partial = { - user: createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }), + user: createMockUserProfile({ + role: 'admin', + user: { user_id: 'admin-id', email: 'admin@test.com' }, + }), }; // Act @@ -374,7 +448,10 @@ describe('Passport Configuration', () => { it('should return 403 Forbidden if user does not have "admin" role', () => { // Arrange const mockReq: Partial = { - user: createMockUserProfile({ role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }), + user: createMockUserProfile({ + role: 'user', + user: { user_id: 'user-id', email: 'user@test.com' }, + }), }; // Act @@ -383,7 +460,9 @@ describe('Passport Configuration', () => { // Assert expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed. expect(mockRes.status).toHaveBeenCalledWith(403); - expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' }); + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Forbidden: Administrator access required.', + }); }); it('should return 403 Forbidden if req.user is missing', () => { @@ -413,7 +492,9 @@ describe('Passport Configuration', () => { // Assert expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed. expect(mockRes.status).toHaveBeenCalledWith(403); - expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' }); + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Forbidden: Administrator access required.', + }); }); }); @@ -428,10 +509,13 @@ describe('Passport Configuration', () => { it('should populate req.user and call next() if authentication succeeds', () => { // Arrange const mockReq = {} as Request; - const mockUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }); + const mockUser = createMockUserProfile({ + role: 'admin', + user: { user_id: 'admin-id', email: 'admin@test.com' }, + }); // Mock passport.authenticate to call its callback with a user vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(null, mockUser, undefined) + (_strategy, _options, callback) => () => callback?.(null, mockUser, undefined), ); // Act @@ -446,7 +530,7 @@ describe('Passport Configuration', () => { // Arrange const mockReq = {} as Request; vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(null, false, undefined) + (_strategy, _options, callback) => () => callback?.(null, false, undefined), ); optionalAuth(mockReq, mockRes as Response, mockNext); @@ -461,7 +545,7 @@ describe('Passport Configuration', () => { const mockInfo = { message: 'Token expired' }; // Mock passport.authenticate to call its callback with an info object vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(null, false, mockInfo) + (_strategy, _options, callback) => () => callback?.(null, false, mockInfo), ); // Act @@ -479,7 +563,7 @@ describe('Passport Configuration', () => { const mockInfoError = new Error('Token is malformed'); // Mock passport.authenticate to call its callback with an info object vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(null, false, mockInfoError) + (_strategy, _options, callback) => () => callback?.(null, false, mockInfoError), ); // Act @@ -487,7 +571,10 @@ describe('Passport Configuration', () => { // Assert // info.message is 'Token is malformed' - expect(logger.info).toHaveBeenCalledWith({ info: 'Token is malformed' }, 'Optional auth info:'); + expect(logger.info).toHaveBeenCalledWith( + { info: 'Token is malformed' }, + 'Optional auth info:', + ); expect(mockNext).toHaveBeenCalledTimes(1); }); @@ -497,14 +584,17 @@ describe('Passport Configuration', () => { const mockInfo = { custom: 'some info' }; // Mock passport.authenticate to call its callback with a custom info object vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any) + (_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any), ); // Act optionalAuth(mockReq, mockRes as Response, mockNext); // Assert - expect(logger.info).toHaveBeenCalledWith({ info: mockInfo.toString() }, 'Optional auth info:'); + expect(logger.info).toHaveBeenCalledWith( + { info: mockInfo.toString() }, + 'Optional auth info:', + ); expect(mockNext).toHaveBeenCalledTimes(1); }); @@ -514,7 +604,7 @@ describe('Passport Configuration', () => { const authError = new Error('Malformed token'); // Mock passport.authenticate to call its callback with an error vi.mocked(passport.authenticate).mockImplementation( - (_strategy, _options, callback) => () => callback?.(authError, false, undefined) + (_strategy, _options, callback) => () => callback?.(authError, false, undefined), ); // Act @@ -567,4 +657,4 @@ describe('Passport Configuration', () => { expect(mockNext).toHaveBeenCalledTimes(1); }); }); -}); \ No newline at end of file +}); diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts index c10e582d..7822ff6d 100644 --- a/src/services/aiService.server.test.ts +++ b/src/services/aiService.server.test.ts @@ -18,26 +18,30 @@ import { logger as mockLoggerInstance } from './logger.server'; // Explicitly unmock the service under test to ensure we import the real implementation. vi.unmock('./aiService.server'); +const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp } = vi.hoisted(() => { + const mockGenerateContent = vi.fn(); + const mockToBuffer = vi.fn(); + const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer })); + const mockSharp = vi.fn(() => ({ extract: mockExtract })); + return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp }; +}); + // Mock sharp, as it's a direct dependency of the service. -const mockToBuffer = vi.fn(); -const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer })); -const mockSharp = vi.fn(() => ({ extract: mockExtract })); vi.mock('sharp', () => ({ __esModule: true, default: mockSharp, })); // Mock @google/genai -const mockGenerateContent = vi.fn(); vi.mock('@google/genai', () => { return { - GoogleGenAI: vi.fn(function() { + GoogleGenAI: vi.fn(function () { return { models: { - generateContent: mockGenerateContent - } + generateContent: mockGenerateContent, + }, }; - }) + }), }; }); @@ -55,9 +59,9 @@ describe('AI Service (Server)', () => { vi.clearAllMocks(); // Reset modules to ensure the service re-initializes with the mocks - mockAiClient.generateContent.mockResolvedValue({ - text: '[]', - candidates: [] + mockAiClient.generateContent.mockResolvedValue({ + text: '[]', + candidates: [], }); }); @@ -82,12 +86,16 @@ describe('AI Service (Server)', () => { it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => { console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'"); - console.log(`PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`); + console.log( + `PRE-TEST ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`, + ); // Simulate a non-test environment process.env.NODE_ENV = 'production'; delete process.env.GEMINI_API_KEY; delete process.env.VITEST_POOL_ID; - console.log(`POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`); + console.log( + `POST-MANIPULATION ENV: NODE_ENV=${process.env.NODE_ENV}, VITEST_POOL_ID=${process.env.VITEST_POOL_ID}`, + ); let error: Error | undefined; // Dynamically import the class to re-evaluate the constructor logic @@ -100,7 +108,9 @@ describe('AI Service (Server)', () => { error = e as Error; } expect(error).toBeInstanceOf(Error); - expect(error?.message).toBe('GEMINI_API_KEY environment variable not set for server-side AI calls.'); + expect(error?.message).toBe( + 'GEMINI_API_KEY environment variable not set for server-side AI calls.', + ); }); it('should use a mock placeholder if API key is missing in a test environment', async () => { @@ -113,40 +123,46 @@ describe('AI Service (Server)', () => { const service = new AIService(mockLoggerInstance); // Assert: Check that the warning was logged and the mock client is in use - expect(mockLoggerInstance.warn).toHaveBeenCalledWith('[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.'); - await expect((service as any).aiClient.generateContent({ contents: [] })).resolves.toBeDefined(); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + '[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.', + ); + await expect( + (service as any).aiClient.generateContent({ contents: [] }), + ).resolves.toBeDefined(); }); it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => { process.env.GEMINI_API_KEY = 'test-key'; // We need to force the constructor to use the real client logic, not the injected mock. // So we instantiate AIService without passing aiClient. - + // Reset modules to pick up the mock for @google/genai vi.resetModules(); const { AIService } = await import('./aiService.server'); const service = new AIService(mockLoggerInstance); - + // Access the private aiClient (which is now the adapter) const adapter = (service as any).aiClient; - + const request = { contents: [{ parts: [{ text: 'test' }] }] }; await adapter.generateContent(request); - + expect(mockGenerateContent).toHaveBeenCalledWith({ model: 'gemini-2.5-flash', - ...request + ...request, }); }); - + it('should throw error if adapter is called without content', async () => { - process.env.GEMINI_API_KEY = 'test-key'; - vi.resetModules(); - const { AIService } = await import('./aiService.server'); - const service = new AIService(mockLoggerInstance); - const adapter = (service as any).aiClient; - - await expect(adapter.generateContent({})).rejects.toThrow('AIService.generateContent requires at least one content element.'); + process.env.GEMINI_API_KEY = 'test-key'; + vi.resetModules(); + const { AIService } = await import('./aiService.server'); + const service = new AIService(mockLoggerInstance); + const adapter = (service as any).aiClient; + + await expect(adapter.generateContent({})).rejects.toThrow( + 'AIService.generateContent requires at least one content element.', + ); }); }); @@ -156,11 +172,15 @@ describe('AI Service (Server)', () => { { "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 }, { "raw_item_description": "AVOCADO", "price_paid_cents": 299 } ]`; - + mockAiClient.generateContent.mockResolvedValue({ text: mockAiResponseText, candidates: [] }); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - const result = await aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance); + const result = await aiServiceInstance.extractItemsFromReceiptImage( + 'path/to/image.jpg', + 'image/jpeg', + mockLoggerInstance, + ); expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1); expect(result).toEqual([ @@ -173,9 +193,13 @@ describe('AI Service (Server)', () => { mockAiClient.generateContent.mockResolvedValue({ text: 'This is not JSON.', candidates: [] }); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance)).rejects.toThrow( - 'AI response did not contain a valid JSON array.' - ); + await expect( + aiServiceInstance.extractItemsFromReceiptImage( + 'path/to/image.jpg', + 'image/jpeg', + mockLoggerInstance, + ), + ).rejects.toThrow('AI response did not contain a valid JSON array.'); }); it('should throw an error if the AI API call fails', async () => { @@ -183,16 +207,24 @@ describe('AI Service (Server)', () => { mockAiClient.generateContent.mockRejectedValue(apiError); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - await expect(aiServiceInstance.extractItemsFromReceiptImage('path/to/image.jpg', 'image/jpeg', mockLoggerInstance)) - .rejects.toThrow(apiError); + await expect( + aiServiceInstance.extractItemsFromReceiptImage( + 'path/to/image.jpg', + 'image/jpeg', + mockLoggerInstance, + ), + ).rejects.toThrow(apiError); expect(mockLoggerInstance.error).toHaveBeenCalledWith( - { err: apiError }, "[extractItemsFromReceiptImage] An error occurred during the process." + { err: apiError }, + '[extractItemsFromReceiptImage] An error occurred during the process.', ); }); }); describe('extractCoreDataFromFlyerImage', () => { - const mockMasterItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })]; + const mockMasterItems: MasterGroceryItem[] = [ + createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }), + ]; it('should extract and post-process flyer data correctly', async () => { const mockAiResponse = { @@ -200,14 +232,37 @@ describe('AI Service (Server)', () => { valid_from: '2024-01-01', valid_to: '2024-01-07', items: [ - { item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', category_name: 'Produce', master_item_id: 1 }, - { item: 'Oranges', price_display: null, price_in_cents: null, quantity: undefined, category_name: null, master_item_id: null }, + { + item: 'Apples', + price_display: '$1.99', + price_in_cents: 199, + quantity: '1lb', + category_name: 'Produce', + master_item_id: 1, + }, + { + item: 'Oranges', + price_display: null, + price_in_cents: null, + quantity: undefined, + category_name: null, + master_item_id: null, + }, ], }; - mockAiClient.generateContent.mockResolvedValue({ text: JSON.stringify(mockAiResponse), candidates: [] }); + mockAiClient.generateContent.mockResolvedValue({ + text: JSON.stringify(mockAiResponse), + candidates: [], + }); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - const result = await aiServiceInstance.extractCoreDataFromFlyerImage([{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], mockMasterItems, undefined, undefined, mockLoggerInstance); + const result = await aiServiceInstance.extractCoreDataFromFlyerImage( + [{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }], + mockMasterItems, + undefined, + undefined, + mockLoggerInstance, + ); expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1); expect(result.store_name).toBe('Test Store'); @@ -221,20 +276,36 @@ describe('AI Service (Server)', () => { mockAiClient.generateContent.mockResolvedValue({ text: 'not a json object', candidates: [] }); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow( - 'AI response did not contain a valid JSON object.' - ); + await expect( + aiServiceInstance.extractCoreDataFromFlyerImage( + [], + mockMasterItems, + undefined, + undefined, + mockLoggerInstance, + ), + ).rejects.toThrow('AI response did not contain a valid JSON object.'); }); it('should throw an error if the AI response contains malformed JSON', async () => { console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'"); // Arrange: AI returns a string that looks like JSON but is invalid - mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] }); + mockAiClient.generateContent.mockResolvedValue({ + text: '{ "store_name": "Incomplete, }', + candidates: [], + }); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); // Act & Assert - await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)) - .rejects.toThrow('AI response did not contain a valid JSON object.'); + await expect( + aiServiceInstance.extractCoreDataFromFlyerImage( + [], + mockMasterItems, + undefined, + undefined, + mockLoggerInstance, + ), + ).rejects.toThrow('AI response did not contain a valid JSON object.'); }); it('should throw an error if the AI API call fails', async () => { @@ -244,48 +315,103 @@ describe('AI Service (Server)', () => { mockAiClient.generateContent.mockRejectedValue(apiError); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); - // Act & Assert - await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError); - expect(mockLoggerInstance.error).toHaveBeenCalledWith({ err: apiError }, - "[extractCoreDataFromFlyerImage] The entire process failed." + // Act & Assert + await expect( + aiServiceInstance.extractCoreDataFromFlyerImage( + [], + mockMasterItems, + undefined, + undefined, + mockLoggerInstance, + ), + ).rejects.toThrow(apiError); + expect(mockLoggerInstance.error).toHaveBeenCalledWith( + { err: apiError }, + '[extractCoreDataFromFlyerImage] The entire process failed.', ); }); }); describe('_buildFlyerExtractionPrompt (private method)', () => { it('should include a strong hint for userProfileAddress', () => { - const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: undefined, userProfileAddress: string) => string })._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown'); - expect(prompt).toContain('The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.'); + const prompt = ( + aiServiceInstance as unknown as { + _buildFlyerExtractionPrompt: ( + masterItems: [], + submitterIp: undefined, + userProfileAddress: string, + ) => string; + } + )._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown'); + expect(prompt).toContain( + 'The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.', + ); }); it('should include a general hint for submitterIp when no address is present', () => { - const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string })._buildFlyerExtractionPrompt([], '123.45.67.89'); - expect(prompt).toContain('The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store\'s region.'); + const prompt = ( + aiServiceInstance as unknown as { + _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string; + } + )._buildFlyerExtractionPrompt([], '123.45.67.89'); + expect(prompt).toContain( + "The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.", + ); }); it('should not include any location hint if no IP or address is provided', () => { - const prompt = (aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string })._buildFlyerExtractionPrompt([]); + const prompt = ( + aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string } + )._buildFlyerExtractionPrompt([]); expect(prompt).not.toContain('Use this as a strong hint'); expect(prompt).not.toContain('Use this as a general hint'); }); }); describe('_parseJsonFromAiResponse (private method)', () => { - it('should return null for undefined or empty input', () => { // This was a duplicate, fixed. - expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(undefined, mockLoggerInstance)).toBeNull(); - expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse('', mockLoggerInstance)).toBeNull(); + it('should return null for undefined or empty input', () => { + // This was a duplicate, fixed. + expect( + ( + aiServiceInstance as unknown as { + _parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null; + } + )._parseJsonFromAiResponse(undefined, mockLoggerInstance), + ).toBeNull(); + expect( + ( + aiServiceInstance as unknown as { + _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null; + } + )._parseJsonFromAiResponse('', mockLoggerInstance), + ).toBeNull(); }); it('should correctly parse a clean JSON string', () => { const json = '{ "key": "value" }'; // Use a type-safe assertion to access the private method for testing. - const result = (aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: Logger) => T | null })._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance); + const result = ( + aiServiceInstance as unknown as { + _parseJsonFromAiResponse: (text: string, logger: Logger) => T | null; + } + )._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance); expect(result).toEqual({ key: 'value' }); }); - it('should extract and parse JSON wrapped in markdown and other text', () => { // This was a duplicate, fixed. - const responseText = 'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.'; - expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => { data: boolean } })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual({ data: true }); + it('should extract and parse JSON wrapped in markdown and other text', () => { + // This was a duplicate, fixed. + const responseText = + 'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.'; + expect( + ( + aiServiceInstance as unknown as { + _parseJsonFromAiResponse: ( + text: string, + logger: typeof mockLoggerInstance, + ) => { data: boolean }; + } + )._parseJsonFromAiResponse(responseText, mockLoggerInstance), + ).toEqual({ data: true }); }); it('should handle JSON arrays correctly', () => { @@ -295,7 +421,10 @@ describe('AI Service (Server)', () => { // --- FULL DIAGNOSTIC LOGGING REMAINS FOR PROOF --- console.log('\n--- TEST LOG: "should handle JSON arrays correctly" ---'); console.log(' - Test Input String:', JSON.stringify(responseText)); - const result = (aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance); + const result = (aiServiceInstance as any)._parseJsonFromAiResponse( + responseText, + mockLoggerInstance, + ); console.log(' - Actual Output from function:', JSON.stringify(result)); console.log(' - Expected Output:', JSON.stringify([1, 2, 3])); console.log('--- END TEST LOG ---\n'); @@ -304,22 +433,58 @@ describe('AI Service (Server)', () => { it('should return null for strings without valid JSON', () => { const responseText = 'This is just plain text.'; - expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull(); + expect( + ( + aiServiceInstance as unknown as { + _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null; + } + )._parseJsonFromAiResponse(responseText, mockLoggerInstance), + ).toBeNull(); }); it('should return null for incomplete JSON and log an error', () => { const localLogger = createMockLogger(); const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem); const responseText = '```json\n{ "key": "value"'; // Missing closing brace; - expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull(); // This was a duplicate, fixed. - expect(localLogger.error).toHaveBeenCalledWith(expect.objectContaining({ jsonSlice: '{ "key": "value"' }), "[_parseJsonFromAiResponse] Failed to parse JSON slice."); + expect( + (localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger), + ).toBeNull(); // This was a duplicate, fixed. + expect(localLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ jsonSlice: '{ "key": "value"' }), + '[_parseJsonFromAiResponse] Failed to parse JSON slice.', + ); }); }); describe('_normalizeExtractedItems (private method)', () => { it('should replace null or undefined fields with default values', () => { - const rawItems: { item: string; price_display: null; quantity: undefined; category_name: null; master_item_id: null; }[] = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }]; - const [normalized] = (aiServiceInstance as unknown as { _normalizeExtractedItems: (items: typeof rawItems) => { price_display: string, quantity: string, category_name: string, master_item_id: undefined }[] })._normalizeExtractedItems(rawItems); + const rawItems: { + item: string; + price_display: null; + quantity: undefined; + category_name: null; + master_item_id: null; + }[] = [ + { + item: 'Test', + price_display: null, + quantity: undefined, + category_name: null, + master_item_id: null, + }, + ]; + const [normalized] = ( + aiServiceInstance as unknown as { + _normalizeExtractedItems: ( + items: typeof rawItems, + ) => { + price_display: string; + quantity: string; + category_name: string; + master_item_id: undefined; + }[]; + } + )._normalizeExtractedItems(rawItems); expect(normalized.price_display).toBe(''); expect(normalized.quantity).toBe(''); expect(normalized.category_name).toBe('Other/Miscellaneous'); @@ -340,7 +505,13 @@ describe('AI Service (Server)', () => { // Mock AI response mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] }); - const result = await aiServiceInstance.extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType, mockLoggerInstance); + const result = await aiServiceInstance.extractTextFromImageArea( + imagePath, + 'image/jpeg', + cropArea, + extractionType, + mockLoggerInstance, + ); expect(mockSharp).toHaveBeenCalledWith(imagePath); expect(mockExtract).toHaveBeenCalledWith({ @@ -351,7 +522,7 @@ describe('AI Service (Server)', () => { }); expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1); - + interface AiCallArgs { contents: { parts: { @@ -361,37 +532,59 @@ describe('AI Service (Server)', () => { }[]; } const aiCallArgs = mockAiClient.generateContent.mock.calls[0][0] as AiCallArgs; - expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?'); + expect(aiCallArgs.contents[0].parts[0].text).toContain( + 'What is the store name in this image?', + ); expect(result.text).toBe('Super Store'); }); it('should throw an error if the AI API call fails', async () => { - console.log("TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)"); + console.log( + "TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)", + ); const apiError = new Error('API Error'); mockAiClient.generateContent.mockRejectedValue(apiError); mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data')); - await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates', mockLoggerInstance)) - .rejects.toThrow(apiError); + await expect( + aiServiceInstance.extractTextFromImageArea( + 'path', + 'image/jpeg', + { x: 0, y: 0, width: 10, height: 10 }, + 'dates', + mockLoggerInstance, + ), + ).rejects.toThrow(apiError); expect(mockLoggerInstance.error).toHaveBeenCalledWith( - { err: apiError }, `[extractTextFromImageArea] An error occurred for type dates.` + { err: apiError }, + `[extractTextFromImageArea] An error occurred for type dates.`, ); }); }); describe('planTripWithMaps', () => { - const mockUserLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) }; + const mockUserLocation: GeolocationCoordinates = { + latitude: 45, + longitude: -75, + accuracy: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + toJSON: () => ({}), + }; const mockStore = { name: 'Test Store' }; it('should throw a "feature disabled" error', async () => { // This test verifies the current implementation which has the feature disabled. - await expect(aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance)) - .rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs."); - + await expect( + aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance), + ).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs."); + // Also verify that the warning is logged expect(mockLoggerInstance.warn).toHaveBeenCalledWith( - "[AIService] planTripWithMaps called, but feature is disabled. Throwing error." + '[AIService] planTripWithMaps called, but feature is disabled. Throwing error.', ); }); }); -}); \ No newline at end of file +}); diff --git a/src/services/db/user.db.test.ts b/src/services/db/user.db.test.ts index 1f7e8075..ac07dcc9 100644 --- a/src/services/db/user.db.test.ts +++ b/src/services/db/user.db.test.ts @@ -33,13 +33,19 @@ import type { Profile, ActivityLogItem, SearchQuery, UserProfile } from '../../t // Update mocks to put methods on prototype so spyOn works in exportUserData tests vi.mock('./shopping.db', () => ({ ShoppingRepository: class { - getShoppingLists() { return Promise.resolve([]); } - createShoppingList() { return Promise.resolve({}); } + getShoppingLists() { + return Promise.resolve([]); + } + createShoppingList() { + return Promise.resolve({}); + } }, })); vi.mock('./personalization.db', () => ({ PersonalizationRepository: class { - getWatchedItems() { return Promise.resolve([]); } + getWatchedItems() { + return Promise.resolve([]); + } }, })); @@ -56,7 +62,10 @@ describe('User DB Service', () => { vi.clearAllMocks(); userRepo = new UserRepository(mockPoolInstance as unknown as PoolClient); // Provide a default mock implementation for withTransaction for all tests. - vi.mocked(withTransaction).mockImplementation(async (callback: (client: PoolClient) => Promise) => callback(mockPoolInstance as unknown as PoolClient)); + vi.mocked(withTransaction).mockImplementation( + async (callback: (client: PoolClient) => Promise) => + callback(mockPoolInstance as unknown as PoolClient), + ); }); describe('findUserByEmail', () => { @@ -66,22 +75,33 @@ describe('User DB Service', () => { const result = await userRepo.findUserByEmail('test@example.com', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['test@example.com']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('FROM public.users WHERE email = $1'), + ['test@example.com'], + ); expect(result).toEqual(mockUser); }); it('should return undefined if user is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); const result = await userRepo.findUserByEmail('notfound@example.com', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['notfound@example.com']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('FROM public.users WHERE email = $1'), + ['notfound@example.com'], + ); expect(result).toBeUndefined(); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserByEmail('test@example.com', mockLogger)).rejects.toThrow('Failed to retrieve user from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'test@example.com' }, 'Database error in findUserByEmail'); + await expect(userRepo.findUserByEmail('test@example.com', mockLogger)).rejects.toThrow( + 'Failed to retrieve user from database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, email: 'test@example.com' }, + 'Database error in findUserByEmail', + ); }); }); @@ -91,8 +111,15 @@ describe('User DB Service', () => { const now = new Date().toISOString(); // This is the flat structure returned by the DB query inside createUser const mockDbProfile = { - user_id: 'new-user-id', email: 'new@example.com', role: 'user', full_name: 'New User', - avatar_url: null, points: 0, preferences: null, created_at: now, updated_at: now + user_id: 'new-user-id', + email: 'new@example.com', + role: 'user', + full_name: 'New User', + avatar_url: null, + points: 0, + preferences: null, + created_at: now, + updated_at: now, }; // This is the nested structure the function is expected to return const expectedProfile: UserProfile = { @@ -115,15 +142,26 @@ describe('User DB Service', () => { return callback(mockClient as unknown as PoolClient); }); - const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger); + const result = await userRepo.createUser( + 'new@example.com', + 'hashedpass', + { full_name: 'New User' }, + mockLogger, + ); - console.log('[TEST DEBUG] createUser - Result from function:', JSON.stringify(result, null, 2)); - console.log('[TEST DEBUG] createUser - Expected result:', JSON.stringify(expectedProfile, null, 2)); + console.log( + '[TEST DEBUG] createUser - Result from function:', + JSON.stringify(result, null, 2), + ); + console.log( + '[TEST DEBUG] createUser - Expected result:', + JSON.stringify(expectedProfile, null, 2), + ); // Use objectContaining because the real implementation might have other DB-generated fields. expect(result).toEqual(expect.objectContaining(expectedProfile)); expect(withTransaction).toHaveBeenCalledTimes(1); - }); + }); it('should rollback the transaction if creating the user fails', async () => { const dbError = new Error('User insert failed'); @@ -134,8 +172,13 @@ describe('User DB Service', () => { throw dbError; }); - await expect(userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'fail@example.com' }, 'Error during createUser transaction'); + await expect( + userRepo.createUser('fail@example.com', 'badpass', {}, mockLogger), + ).rejects.toThrow('Failed to create user in database.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, email: 'fail@example.com' }, + 'Error during createUser transaction', + ); }); it('should rollback the transaction if fetching the final profile fails', async () => { @@ -151,8 +194,13 @@ describe('User DB Service', () => { throw dbError; }); - await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'fail@example.com' }, 'Error during createUser transaction'); + await expect(userRepo.createUser('fail@example.com', 'pass', {}, mockLogger)).rejects.toThrow( + 'Failed to create user in database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, email: 'fail@example.com' }, + 'Error during createUser transaction', + ); }); it('should throw UniqueConstraintError if the email already exists', async () => { @@ -174,7 +222,9 @@ describe('User DB Service', () => { } expect(withTransaction).toHaveBeenCalledTimes(1); - expect(mockLogger.warn).toHaveBeenCalledWith(`Attempted to create a user with an existing email: exists@example.com`); + expect(mockLogger.warn).toHaveBeenCalledWith( + `Attempted to create a user with an existing email: exists@example.com`, + ); }); it('should throw an error if profile is not found after user creation', async () => { @@ -187,12 +237,19 @@ describe('User DB Service', () => { .mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user succeeds .mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing // The callback will throw, which is caught and re-thrown by withTransaction - await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow('Failed to create or retrieve user profile after registration.'); + await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow( + 'Failed to create or retrieve user profile after registration.', + ); throw new Error('Internal failure'); // Simulate re-throw from withTransaction }); - await expect(userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger)).rejects.toThrow('Failed to create user in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), email: 'no-profile@example.com' }, 'Error during createUser transaction'); + await expect( + userRepo.createUser('no-profile@example.com', 'pass', {}, mockLogger), + ).rejects.toThrow('Failed to create user in database.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), email: 'no-profile@example.com' }, + 'Error during createUser transaction', + ); }); }); @@ -218,7 +275,6 @@ describe('User DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rows: [mockDbResult] }); const expectedResult = { - user_id: '123', full_name: 'Test User', avatar_url: null, role: 'user', @@ -228,7 +284,6 @@ describe('User DB Service', () => { created_at: now, updated_at: now, user: { user_id: '123', email: 'test@example.com' }, - email: 'test@example.com', password_hash: 'hash', failed_login_attempts: 0, last_failed_login: null, @@ -237,10 +292,19 @@ describe('User DB Service', () => { const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger); - console.log('[TEST DEBUG] findUserWithProfileByEmail - Result from function:', JSON.stringify(result, null, 2)); - console.log('[TEST DEBUG] findUserWithProfileByEmail - Expected result:', JSON.stringify(expectedResult, null, 2)); + console.log( + '[TEST DEBUG] findUserWithProfileByEmail - Result from function:', + JSON.stringify(result, null, 2), + ); + console.log( + '[TEST DEBUG] findUserWithProfileByEmail - Expected result:', + JSON.stringify(expectedResult, null, 2), + ); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('JOIN public.profiles'), + ['test@example.com'], + ); expect(result).toEqual(expect.objectContaining(expectedResult)); }); @@ -253,8 +317,13 @@ describe('User DB Service', () => { it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserWithProfileByEmail('test@example.com', mockLogger)).rejects.toThrow('Failed to retrieve user with profile from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, email: 'test@example.com' }, 'Database error in findUserWithProfileByEmail'); + await expect( + userRepo.findUserWithProfileByEmail('test@example.com', mockLogger), + ).rejects.toThrow('Failed to retrieve user with profile from database.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, email: 'test@example.com' }, + 'Database error in findUserWithProfileByEmail', + ); }); }); @@ -262,40 +331,65 @@ describe('User DB Service', () => { it('should query for a user by their ID', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }], rowCount: 1 }); await userRepo.findUserById('123', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('FROM public.users WHERE user_id = $1'), + ['123'], + ); }); it('should throw NotFoundError if user is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); - await expect(userRepo.findUserById('not-found-id', mockLogger)).rejects.toThrow('User with ID not-found-id not found.'); + await expect(userRepo.findUserById('not-found-id', mockLogger)).rejects.toThrow( + 'User with ID not-found-id not found.', + ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserById('123', mockLogger)).rejects.toThrow('Failed to retrieve user by ID from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserById'); + await expect(userRepo.findUserById('123', mockLogger)).rejects.toThrow( + 'Failed to retrieve user by ID from database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, userId: '123' }, + 'Database error in findUserById', + ); }); }); - + describe('findUserWithPasswordHashById', () => { it('should query for a user and their password hash by ID', async () => { - mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123', password_hash: 'hash' }], rowCount: 1 }); + mockPoolInstance.query.mockResolvedValue({ + rows: [{ user_id: '123', password_hash: 'hash' }], + rowCount: 1, + }); await userRepo.findUserWithPasswordHashById('123', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT user_id, email, password_hash'), ['123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT user_id, email, password_hash'), + ['123'], + ); }); it('should throw NotFoundError if user is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); - await expect(userRepo.findUserWithPasswordHashById('not-found-id', mockLogger)).rejects.toThrow(NotFoundError); - await expect(userRepo.findUserWithPasswordHashById('not-found-id', mockLogger)).rejects.toThrow('User with ID not-found-id not found.'); + await expect( + userRepo.findUserWithPasswordHashById('not-found-id', mockLogger), + ).rejects.toThrow(NotFoundError); + await expect( + userRepo.findUserWithPasswordHashById('not-found-id', mockLogger), + ).rejects.toThrow('User with ID not-found-id not found.'); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserWithPasswordHashById('123', mockLogger)).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserWithPasswordHashById'); + await expect(userRepo.findUserWithPasswordHashById('123', mockLogger)).rejects.toThrow( + 'Failed to retrieve user with sensitive data by ID from database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, userId: '123' }, + 'Database error in findUserWithPasswordHashById', + ); }); }); @@ -304,52 +398,92 @@ describe('User DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] }); await userRepo.findUserProfileById('123', mockLogger); // The actual query uses 'p.user_id' due to the join alias - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('WHERE p.user_id = $1'), + ['123'], + ); }); it('should throw NotFoundError if user profile is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); - await expect(userRepo.findUserProfileById('not-found-id', mockLogger)).rejects.toThrow('Profile not found for this user.'); + await expect(userRepo.findUserProfileById('not-found-id', mockLogger)).rejects.toThrow( + 'Profile not found for this user.', + ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Connection Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserProfileById('123', mockLogger)).rejects.toThrow('Failed to retrieve user profile from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in findUserProfileById'); + await expect(userRepo.findUserProfileById('123', mockLogger)).rejects.toThrow( + 'Failed to retrieve user profile from database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, userId: '123' }, + 'Database error in findUserProfileById', + ); }); }); describe('updateUserProfile', () => { it('should execute an UPDATE query for the user profile', async () => { - const mockProfile: Profile = { full_name: 'Updated Name', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + const mockProfile: Profile = { + full_name: 'Updated Name', + role: 'user', + points: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); await userRepo.updateUserProfile('123', { full_name: 'Updated Name' }, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.profiles'), expect.any(Array)); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.profiles'), + expect.any(Array), + ); }); it('should execute an UPDATE query for avatar_url', async () => { - const mockProfile: Profile = { avatar_url: 'new-avatar.png', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + const mockProfile: Profile = { + avatar_url: 'new-avatar.png', + role: 'user', + points: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' }, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('avatar_url = $1'), ['new-avatar.png', '123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('avatar_url = $1'), + ['new-avatar.png', '123'], + ); }); it('should execute an UPDATE query for address_id', async () => { - const mockProfile: Profile = { address_id: 99, role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + const mockProfile: Profile = { + address_id: 99, + role: 'user', + points: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); await userRepo.updateUserProfile('123', { address_id: 99 }, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('address_id = $1'), [99, '123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('address_id = $1'), + [99, '123'], + ); }); it('should fetch the current profile if no update fields are provided', async () => { - const mockProfile: Profile = createMockUserProfile({ user: { user_id: '123', email: '123@example.com' }, full_name: 'Current Name' }); + const mockProfile: Profile = createMockUserProfile({ + user: { user_id: '123', email: '123@example.com' }, + full_name: 'Current Name', + }); // FIX: Instead of mocking `mockResolvedValue` on the instance method which might fail if not spied correctly, // we mock the underlying `db.query` call that `findUserProfileById` makes. mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] }); @@ -357,20 +491,30 @@ describe('User DB Service', () => { const result = await userRepo.updateUserProfile('123', { full_name: undefined }, mockLogger); // Check that it calls query for finding profile (since no updates were made) - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT'), expect.any(Array)); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT'), + expect.any(Array), + ); expect(result).toEqual(mockProfile); }); it('should throw an error if the user to update is not found', async () => { // Simulate the DB returning 0 rows affected mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); - await expect(userRepo.updateUserProfile('999', { full_name: 'Fail' }, mockLogger)).rejects.toThrow('User not found or user does not have permission to update.'); + await expect( + userRepo.updateUserProfile('999', { full_name: 'Fail' }, mockLogger), + ).rejects.toThrow('User not found or user does not have permission to update.'); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.updateUserProfile('123', { full_name: 'Fail' }, mockLogger)).rejects.toThrow('Failed to update user profile in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123', profileData: { full_name: 'Fail' } }, 'Database error in updateUserProfile'); + await expect( + userRepo.updateUserProfile('123', { full_name: 'Fail' }, mockLogger), + ).rejects.toThrow('Failed to update user profile in database.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), userId: '123', profileData: { full_name: 'Fail' } }, + 'Database error in updateUserProfile', + ); }); }); @@ -378,19 +522,29 @@ describe('User DB Service', () => { it('should execute an UPDATE query for user preferences', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [{}] }); await userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), [{ darkMode: true }, '123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), + [{ darkMode: true }, '123'], + ); }); it('should throw an error if the user to update is not found', async () => { // Simulate the DB returning 0 rows affected mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] }); - await expect(userRepo.updateUserPreferences('999', { darkMode: true }, mockLogger)).rejects.toThrow('User not found or user does not have permission to update.'); + await expect( + userRepo.updateUserPreferences('999', { darkMode: true }, mockLogger), + ).rejects.toThrow('User not found or user does not have permission to update.'); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger)).rejects.toThrow('Failed to update user preferences in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123', preferences: { darkMode: true } }, 'Database error in updateUserPreferences'); + await expect( + userRepo.updateUserPreferences('123', { darkMode: true }, mockLogger), + ).rejects.toThrow('Failed to update user preferences in database.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), userId: '123', preferences: { darkMode: true } }, + 'Database error in updateUserPreferences', + ); }); }); @@ -398,13 +552,21 @@ describe('User DB Service', () => { it('should execute an UPDATE query for the user password', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.updateUserPassword('123', 'newhash', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET password_hash = $1 WHERE user_id = $2', ['newhash', '123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'UPDATE public.users SET password_hash = $1 WHERE user_id = $2', + ['newhash', '123'], + ); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.updateUserPassword('123', 'newhash', mockLogger)).rejects.toThrow('Failed to update user password in database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in updateUserPassword'); + await expect(userRepo.updateUserPassword('123', 'newhash', mockLogger)).rejects.toThrow( + 'Failed to update user password in database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), userId: '123' }, + 'Database error in updateUserPassword', + ); }); }); @@ -412,13 +574,21 @@ describe('User DB Service', () => { it('should execute a DELETE query for the user', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.deleteUserById('123', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.users WHERE user_id = $1', ['123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'DELETE FROM public.users WHERE user_id = $1', + ['123'], + ); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.deleteUserById('123', mockLogger)).rejects.toThrow('Failed to delete user from database.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in deleteUserById'); + await expect(userRepo.deleteUserById('123', mockLogger)).rejects.toThrow( + 'Failed to delete user from database.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), userId: '123' }, + 'Database error in deleteUserById', + ); }); }); @@ -426,13 +596,21 @@ describe('User DB Service', () => { it('should execute an UPDATE query to save the refresh token', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.saveRefreshToken('123', 'new-token', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', ['new-token', '123']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', + ['new-token', '123'], + ); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.saveRefreshToken('123', 'new-token', mockLogger)).rejects.toThrow('Failed to save refresh token.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), userId: '123' }, 'Database error in saveRefreshToken'); + await expect(userRepo.saveRefreshToken('123', 'new-token', mockLogger)).rejects.toThrow( + 'Failed to save refresh token.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), userId: '123' }, + 'Database error in saveRefreshToken', + ); }); }); @@ -440,22 +618,34 @@ describe('User DB Service', () => { it('should query for a user by their refresh token', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }], rowCount: 1 }); await userRepo.findUserByRefreshToken('a-token', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('WHERE refresh_token = $1'), + ['a-token'], + ); }); it('should throw NotFoundError if token is not found', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 }); - await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(NotFoundError); - await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow('User not found for the given refresh token.'); + await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow( + NotFoundError, + ); + await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow( + 'User not found for the given refresh token.', + ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow('Failed to find user by refresh token.'); + await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow( + 'Failed to find user by refresh token.', + ); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in findUserByRefreshToken'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError }, + 'Database error in findUserByRefreshToken', + ); }); }); @@ -464,7 +654,8 @@ describe('User DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.deleteRefreshToken('a-token', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( - 'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', ['a-token'] + 'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', + ['a-token'], ); }); @@ -474,7 +665,10 @@ describe('User DB Service', () => { await expect(userRepo.deleteRefreshToken('a-token', mockLogger)).resolves.toBeUndefined(); // We can still check that the query was attempted. expect(mockPoolInstance.query).toHaveBeenCalled(); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in deleteRefreshToken'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error) }, + 'Database error in deleteRefreshToken', + ); }); }); @@ -483,23 +677,36 @@ describe('User DB Service', () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); const expires = new Date(); await userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE user_id = $1', ['123']); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.password_reset_tokens'), ['123', 'token-hash', expires]); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'DELETE FROM public.password_reset_tokens WHERE user_id = $1', + ['123'], + ); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.password_reset_tokens'), + ['123', 'token-hash', expires], + ); }); it('should throw ForeignKeyConstraintError if user does not exist', async () => { const dbError = new Error('violates foreign key constraint'); (dbError as Error & { code: string }).code = '23503'; mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger)).rejects.toThrow(ForeignKeyConstraintError); + await expect( + userRepo.createPasswordResetToken('non-existent-user', 'hash', new Date(), mockLogger), + ).rejects.toThrow(ForeignKeyConstraintError); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); const expires = new Date(); - await expect(userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger)).rejects.toThrow('Failed to create password reset token.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: '123' }, 'Database error in createPasswordResetToken'); + await expect( + userRepo.createPasswordResetToken('123', 'token-hash', expires, mockLogger), + ).rejects.toThrow('Failed to create password reset token.'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, userId: '123' }, + 'Database error in createPasswordResetToken', + ); }); }); @@ -507,13 +714,20 @@ describe('User DB Service', () => { it('should query for tokens where expires_at > NOW()', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.getValidResetTokens(mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE expires_at > NOW()')); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + expect.stringContaining('WHERE expires_at > NOW()'), + ); }); it('should throw a generic error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); - await expect(userRepo.getValidResetTokens(mockLogger)).rejects.toThrow('Failed to retrieve valid reset tokens.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Database error in getValidResetTokens'); + await expect(userRepo.getValidResetTokens(mockLogger)).rejects.toThrow( + 'Failed to retrieve valid reset tokens.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error) }, + 'Database error in getValidResetTokens', + ); }); }); @@ -521,13 +735,19 @@ describe('User DB Service', () => { it('should execute a DELETE query for the token hash', async () => { mockPoolInstance.query.mockResolvedValue({ rows: [] }); await userRepo.deleteResetToken('token-hash', mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', ['token-hash']); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'DELETE FROM public.password_reset_tokens WHERE token_hash = $1', + ['token-hash'], + ); }); it('should log an error if the database query fails', async () => { mockPoolInstance.query.mockRejectedValue(new Error('DB Error')); await userRepo.deleteResetToken('token-hash', mockLogger); - expect(mockLogger.error).toHaveBeenCalledWith({ err: expect.any(Error), tokenHash: 'token-hash' }, 'Database error in deleteResetToken'); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: expect.any(Error), tokenHash: 'token-hash' }, + 'Database error in deleteResetToken', + ); }); }); @@ -535,16 +755,25 @@ describe('User DB Service', () => { it('should execute a DELETE query for expired tokens and return the count', async () => { mockPoolInstance.query.mockResolvedValue({ rowCount: 5 }); const result = await userRepo.deleteExpiredResetTokens(mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()'); + expect(mockPoolInstance.query).toHaveBeenCalledWith( + 'DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()', + ); expect(result).toBe(5); - expect(mockLogger.info).toHaveBeenCalledWith('[DB deleteExpiredResetTokens] Deleted 5 expired password reset tokens.'); + expect(mockLogger.info).toHaveBeenCalledWith( + '[DB deleteExpiredResetTokens] Deleted 5 expired password reset tokens.', + ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.deleteExpiredResetTokens(mockLogger)).rejects.toThrow('Failed to delete expired password reset tokens.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in deleteExpiredResetTokens'); + await expect(userRepo.deleteExpiredResetTokens(mockLogger)).rejects.toThrow( + 'Failed to delete expired password reset tokens.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError }, + 'Database error in deleteExpiredResetTokens', + ); }); }); @@ -562,7 +791,9 @@ describe('User DB Service', () => { const { PersonalizationRepository } = await import('./personalization.db'); const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById'); - findProfileSpy.mockResolvedValue(createMockUserProfile({ user: { user_id: '123', email: '123@example.com' } })); + findProfileSpy.mockResolvedValue( + createMockUserProfile({ user: { user_id: '123', email: '123@example.com' } }), + ); const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems'); getWatchedItemsSpy.mockResolvedValue([]); const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists'); @@ -583,19 +814,27 @@ describe('User DB Service', () => { // Arrange: Mock findUserProfileById to throw a NotFoundError, as per its contract (ADR-001). // The exportUserData function will catch this and re-throw a generic error. const { NotFoundError } = await import('./errors.db'); - vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new NotFoundError('Profile not found')); + vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue( + new NotFoundError('Profile not found'), + ); // Act & Assert: The outer function catches the NotFoundError and re-throws it. - await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.'); + await expect(exportUserData('123', mockLogger)).rejects.toThrow( + 'Failed to export user data.', + ); expect(withTransaction).toHaveBeenCalledTimes(1); }); it('should throw an error if the database query fails', async () => { - // Arrange: Force a failure in one of the parallel calls - vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new Error('DB Error')); + // Arrange: Force a failure in one of the parallel calls + vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue( + new Error('DB Error'), + ); // Act & Assert - await expect(exportUserData('123', mockLogger)).rejects.toThrow('Failed to export user data.'); + await expect(exportUserData('123', mockLogger)).rejects.toThrow( + 'Failed to export user data.', + ); expect(withTransaction).toHaveBeenCalledTimes(1); }); }); @@ -606,7 +845,7 @@ describe('User DB Service', () => { await userRepo.followUser('follower-1', 'following-1', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( 'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING', - ['follower-1', 'following-1'] + ['follower-1', 'following-1'], ); }); @@ -614,14 +853,21 @@ describe('User DB Service', () => { const dbError = new Error('violates foreign key constraint'); (dbError as Error & { code: string }).code = '23503'; mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.followUser('follower-1', 'non-existent-user', mockLogger)).rejects.toThrow(ForeignKeyConstraintError); + await expect( + userRepo.followUser('follower-1', 'non-existent-user', mockLogger), + ).rejects.toThrow(ForeignKeyConstraintError); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.followUser('follower-1', 'following-1', mockLogger)).rejects.toThrow('Failed to follow user.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in followUser'); + await expect(userRepo.followUser('follower-1', 'following-1', mockLogger)).rejects.toThrow( + 'Failed to follow user.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, followerId: 'follower-1', followingId: 'following-1' }, + 'Database error in followUser', + ); }); }); @@ -631,15 +877,20 @@ describe('User DB Service', () => { await userRepo.unfollowUser('follower-1', 'following-1', mockLogger); expect(mockPoolInstance.query).toHaveBeenCalledWith( 'DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', - ['follower-1', 'following-1'] + ['follower-1', 'following-1'], ); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.unfollowUser('follower-1', 'following-1', mockLogger)).rejects.toThrow('Failed to unfollow user.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, followerId: 'follower-1', followingId: 'following-1' }, 'Database error in unfollowUser'); + await expect(userRepo.unfollowUser('follower-1', 'following-1', mockLogger)).rejects.toThrow( + 'Failed to unfollow user.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, followerId: 'follower-1', followingId: 'following-1' }, + 'Database error in unfollowUser', + ); }); }); @@ -662,7 +913,7 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith( expect.stringContaining('FROM public.activity_log al'), - ['user-123', 10, 0] + ['user-123', 10, 0], ); expect(result).toEqual(mockFeedItems); }); @@ -676,8 +927,13 @@ describe('User DB Service', () => { it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.getUserFeed('user-123', 10, 0, mockLogger)).rejects.toThrow('Failed to retrieve user feed.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, userId: 'user-123', limit: 10, offset: 0 }, 'Database error in getUserFeed'); + await expect(userRepo.getUserFeed('user-123', 10, 0, mockLogger)).rejects.toThrow( + 'Failed to retrieve user feed.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, userId: 'user-123', limit: 10, offset: 0 }, + 'Database error in getUserFeed', + ); }); }); @@ -700,12 +956,7 @@ describe('User DB Service', () => { expect(mockPoolInstance.query).toHaveBeenCalledWith( 'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4) RETURNING *', - [ - queryData.user_id, - queryData.query_text, - queryData.result_count, - queryData.was_successful, - ] + [queryData.user_id, queryData.query_text, queryData.result_count, queryData.was_successful], ); expect(result).toEqual(mockLoggedQuery); }); @@ -726,14 +977,24 @@ describe('User DB Service', () => { await userRepo.logSearchQuery(queryData, mockLogger); - expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [null, 'anonymous search', 10, true]); + expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), [ + null, + 'anonymous search', + 10, + true, + ]); }); it('should throw a generic error if the database query fails', async () => { const dbError = new Error('DB Error'); mockPoolInstance.query.mockRejectedValue(dbError); - await expect(userRepo.logSearchQuery({ query_text: 'fail' }, mockLogger)).rejects.toThrow('Failed to log search query.'); - expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, queryData: { query_text: 'fail' } }, 'Database error in logSearchQuery'); + await expect(userRepo.logSearchQuery({ query_text: 'fail' }, mockLogger)).rejects.toThrow( + 'Failed to log search query.', + ); + expect(mockLogger.error).toHaveBeenCalledWith( + { err: dbError, queryData: { query_text: 'fail' } }, + 'Database error in logSearchQuery', + ); }); }); -}); \ No newline at end of file +});