// src/providers/AuthProvider.test.tsx import React, { useContext, useState } from 'react'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { AuthProvider } from './AuthProvider'; import { AuthContext } from '../contexts/AuthContext'; import * as tokenStorage from '../services/tokenStorage'; import { createMockUserProfile } from '../tests/utils/mockFactories'; import * as apiClient from '../services/apiClient'; // Mocks // The apiClient is mocked globally in `src/tests/setup/globalApiMock.ts`. vi.mock('../services/tokenStorage'); vi.mock('../services/logger.client', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); const mockedApiClient = vi.mocked(apiClient); const mockedTokenStorage = tokenStorage as Mocked; const mockProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@example.com' }, }); // A simple consumer component to access and display context values const TestConsumer = () => { const context = useContext(AuthContext); const [error, setError] = useState(null); if (!context) { return
No Context
; } const handleLoginWithoutProfile = async () => { try { await context.login('test-token-no-profile'); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; return (
{context.authStatus}
{context.userProfile?.user.email ?? 'No User'}
{context.isLoading.toString()}
{error &&
{error}
}
); }; const renderWithProvider = () => { return render( , ); }; describe('AuthProvider', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should start in "Determining..." state and transition to "SIGNED_OUT" if no token exists', async () => { mockedTokenStorage.getToken.mockReturnValue(null); renderWithProvider(); // The transition happens synchronously in the effect when no token is present, // so 'Determining...' might be skipped or flashed too quickly for the test runner. // We check that it settles correctly. await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); expect(screen.getByTestId('is-loading')).toHaveTextContent('false'); }); expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled(); }); it('should transition to "AUTHENTICATED" if a valid token exists', async () => { mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue( new Response(JSON.stringify(mockProfile)), ); renderWithProvider(); await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'); expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com'); expect(screen.getByTestId('is-loading')).toHaveTextContent('false'); }); expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1); }); it('should handle token validation failure by signing out', async () => { mockedTokenStorage.getToken.mockReturnValue('invalid-token'); mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid Token')); renderWithProvider(); await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); }); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); }); it('should handle a valid token that returns no profile by signing out', async () => { // This test covers lines 51-55 mockedTokenStorage.getToken.mockReturnValue('valid-token-no-profile'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue( new Response(JSON.stringify(null)), ); renderWithProvider(); expect(screen.getByTestId('auth-status')).toHaveTextContent('Determining...'); await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); }); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(screen.getByTestId('user-email')).toHaveTextContent('No User'); expect(screen.getByTestId('is-loading')).toHaveTextContent('false'); }); it('should log in a user with provided profile data', async () => { mockedTokenStorage.getToken.mockReturnValue(null); renderWithProvider(); await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT')); const loginButton = screen.getByRole('button', { name: 'Login with Profile' }); await act(async () => { fireEvent.click(loginButton); }); expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token'); expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'); expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com'); // API should not be called if profile is provided expect(mockedApiClient.getAuthenticatedUserProfile).not.toHaveBeenCalled(); }); it('should log in a user and fetch profile if not provided', async () => { mockedTokenStorage.getToken.mockReturnValue(null); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue( new Response(JSON.stringify(mockProfile)), ); renderWithProvider(); await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT')); const loginButton = screen.getByRole('button', { name: 'Login without Profile' }); await act(async () => { fireEvent.click(loginButton); }); await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'); expect(screen.getByTestId('user-email')).toHaveTextContent('test@example.com'); }); expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile'); expect(mockedApiClient.getAuthenticatedUserProfile).toHaveBeenCalledTimes(1); }); it('should throw an error and log out if profile fetch fails after login', async () => { // This test covers lines 109-111 mockedTokenStorage.getToken.mockReturnValue(null); const fetchError = new Error('API is down'); mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(fetchError); renderWithProvider(); await waitFor(() => { expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); }); const loginButton = screen.getByRole('button', { name: 'Login without Profile' }); // Click the button that triggers the failing login fireEvent.click(loginButton); // After the error is thrown, the state should be rolled back await waitFor(() => { // The error is now caught and displayed by the TestConsumer expect(screen.getByTestId('error-display')).toHaveTextContent( 'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.', ); expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile'); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); }); }); it('should log out the user', async () => { mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue( new Response(JSON.stringify(mockProfile)), ); renderWithProvider(); await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED')); const logoutButton = screen.getByRole('button', { name: 'Logout' }); fireEvent.click(logoutButton); expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT'); expect(screen.getByTestId('user-email')).toHaveTextContent('No User'); expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); }); it('should update the user profile', async () => { mockedTokenStorage.getToken.mockReturnValue('valid-token'); mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue( new Response(JSON.stringify(mockProfile)), ); renderWithProvider(); await waitFor(() => expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED')); const updateButton = screen.getByRole('button', { name: 'Update Profile' }); fireEvent.click(updateButton); await waitFor(() => { // The profile object is internal, so we can't directly check it. // A good proxy is to see if a component that uses it would re-render. // Since our consumer doesn't display the name, we just confirm the function was called. // In a real app, we'd check the updated UI element. expect(screen.getByTestId('auth-status')).toHaveTextContent('AUTHENTICATED'); }); }); });