// src/hooks/useAuth.test.tsx import React, { ReactNode } from 'react'; import { renderHook, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useAuth } from './useAuth'; import { AuthProvider } from '../providers/AuthProvider'; import * as apiClient from '../services/apiClient'; import type { UserProfile } from '../types'; import * as tokenStorage from '../services/tokenStorage'; import { createMockUserProfile } from '../tests/utils/mockFactories'; import { logger } from '../services/logger.client'; // Mock the dependencies // The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`. vi.mock('../services/tokenStorage'); const mockedApiClient = vi.mocked(apiClient); const mockedTokenStorage = vi.mocked(tokenStorage); const mockProfile: UserProfile = createMockUserProfile({ full_name: 'Test User', points: 100, role: 'user', user: { user_id: 'user-abc-123', email: 'test@example.com' }, }); // Reusable wrapper for rendering the hook within the provider const wrapper = ({ children }: { children: ReactNode }) => {children}; describe('useAuth Hook and AuthProvider', () => { beforeEach(() => { // Reset mocks and storage before each test vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it('throws an error when useAuth is used outside of an AuthProvider', () => { // Suppress console error for this expected failure const originalError = console.error; console.error = vi.fn(); expect(() => renderHook(() => useAuth())).toThrow( 'useAuth must be used within an AuthProvider', ); console.error = originalError; }); it('initializes with a default state', async () => { const { result } = renderHook(() => useAuth(), { wrapper }); // We verify that it starts in a loading state or quickly resolves to signed out // depending on the execution speed. // To avoid flakiness, we just ensure it is in a valid state structure. expect(result.current.userProfile).toBeNull(); // It should eventually settle await waitFor(() => { expect(result.current.isLoading).toBe(false); }); }); describe('Initial Auth Check (useEffect)', () => { it('sets state to SIGNED_OUT if no token is found in storage', async () => { mockedTokenStorage.getToken.mockReturnValue(null); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); }); it('sets state to AUTHENTICATED if a valid token is found', async () => { mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockProfile), } as unknown as Response); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.authStatus).toBe('AUTHENTICATED'); expect(result.current.userProfile).toEqual(mockProfile); // Check that it was called at least once. // React 18 Strict Mode might call effects twice in dev/test environment. expect(mockedApiClient.getAuthenticatedUserProfile.mock.calls.length).toBeGreaterThanOrEqual( 1, ); }); it('sets state to SIGNED_OUT and removes token if validation fails', async () => { mockedTokenStorage.getToken.mockReturnValue('invalid-token'); mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid token')); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); }); }); it('sets state to SIGNED_OUT and removes token if profile fetch returns null after token validation', async () => { mockedTokenStorage.getToken.mockReturnValue('valid-token'); // Mock getAuthenticatedUserProfile to return a 200 OK response with a null body mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(null), // Simulate API returning no profile data } as unknown as Response); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( '[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.', ); }); describe('login function', () => { // This was the failing test it('sets token, fetches profile, and updates state on successful login', async () => { // --- FIX --- // Explicitly mock that no token exists initially to prevent state leakage from other tests. mockedTokenStorage.getToken.mockReturnValue(null); // --- FIX --- // The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object, // as this is the return type of the actual function. The `useApi` hook then // processes this response. This mock is now type-safe. mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockProfile), } as Response); const { result } = renderHook(() => useAuth(), { wrapper }); // 1. Wait for the initial effect to complete and loading to be false console.log('[TEST-DEBUG] Waiting for initial auth check to complete...'); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); console.log( '[TEST-DEBUG] Initial auth check complete. Current status:', result.current.authStatus, ); expect(result.current.authStatus).toBe('SIGNED_OUT'); // 2. Perform login await act(async () => { console.log('[TEST-DEBUG] Calling login function...'); await result.current.login('new-valid-token', mockProfile); console.log('[TEST-DEBUG] Login function promise resolved.'); }); console.log('[TEST-DEBUG] State immediately after login `act` call:', result.current); // 3. Assertions expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('new-valid-token'); // 4. We must wait for the state update inside the hook to propagate await waitFor(() => { console.log( `[TEST-DEBUG] Checking authStatus in waitFor... Current status: ${result.current.authStatus}`, ); expect(result.current.authStatus).toBe('AUTHENTICATED'); }); console.log('[TEST-DEBUG] Final state after successful login:', result.current); expect(result.current.userProfile).toEqual(mockProfile); }); it('logs out and throws an error if profile fetch fails after login', async () => { const fetchError = new Error('API is down'); // The hook will throw, so we mock the rejection. mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(fetchError); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); // The login function should reject the promise it returns. await act(async () => { await expect(result.current.login('new-token')).rejects.toThrow( /Login succeeded, but failed to fetch your data/, ); }); // Should trigger the logout flow expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(result.current.authStatus).toBe('SIGNED_OUT'); // This was a duplicate, fixed. expect(result.current.userProfile).toBeNull(); }); it('logs out and throws an error if profile fetch returns null after login (no profileData)', async () => { // Simulate successful token setting, but subsequent profile fetch returns null mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(null), // Simulate API returning no profile data } as unknown as Response); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); // Call login without profileData, forcing a profile fetch await act(async () => { await expect(result.current.login('new-token-no-profile-data')).rejects.toThrow( 'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.', ); }); // Should trigger the logout flow expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); expect(logger.error).toHaveBeenCalledWith( expect.any(String), // The error message expect.objectContaining({ error: 'Received null or undefined profile from API.' }), ); }); }); describe('logout function', () => { it('removes token and resets auth state', async () => { // Start in a logged-in state by mocking the token storage mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockProfile), } as unknown as Response); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.authStatus).toBe('AUTHENTICATED')); expect(result.current.userProfile).not.toBeNull(); act(() => { result.current.logout(); }); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); }); }); describe('updateProfile function', () => { it('merges new data into the existing profile state', async () => { // Start in a logged-in state mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(mockProfile), } as unknown as Response); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.authStatus).toBe('AUTHENTICATED')); expect(result.current.userProfile?.full_name).toBe('Test User'); const updatedData: Partial = { full_name: 'Test User Updated', points: 150, }; act(() => { result.current.updateProfile(updatedData); }); expect(result.current.userProfile?.full_name).toBe('Test User Updated'); expect(result.current.userProfile?.points).toBe(150); // Ensure other data was not overwritten expect(result.current.userProfile?.role).toBe('user'); }); it('should not update profile if user is not authenticated', async () => { // --- FIX --- // Explicitly mock that no token exists initially to prevent state leakage from other tests. mockedTokenStorage.getToken.mockReturnValue(null); const { result } = renderHook(() => useAuth(), { wrapper }); // Wait for initial check to complete await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.authStatus).toBe('SIGNED_OUT'); expect(result.current.userProfile).toBeNull(); const updatedData: Partial = { full_name: 'Should Not Update', }; act(() => result.current.updateProfile(updatedData)); expect(result.current.userProfile).toBeNull(); }); }); });