import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ReactNode } from 'react'; import { useAuthSession, useAuthModalWorkflow } from './hooks'; import * as authApi from '@/shared/api/auth'; import type { CurrentUser } from '@/shared/types/auth'; import { AuthExpiredError } from '@/shared/api/httpClient'; vi.mock('@/shared/api/auth', () => ({ getCurrentUser: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), })); vi.mock('@/business/campuses/hooks', () => ({ useCampusCatalog: () => ({ campuses: [], isLoading: false, error: null, }), })); const mockNavigate = vi.fn(); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useNavigate: () => mockNavigate, }; }); function wrapper({ children }: { children: ReactNode }) { return {children}; } describe('useAuthSession', () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('initialization', () => { it('starts with loading=true', async () => { vi.mocked(authApi.getCurrentUser).mockImplementation( () => new Promise(() => {}), // Never resolves ); const { result } = renderHook(() => useAuthSession(), { wrapper }); expect(result.current.loading).toBe(true); }); it('fetches current user on mount', async () => { vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: 'user-1', email: 'test@example.com', }); renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(authApi.getCurrentUser).toHaveBeenCalledTimes(1); }); }); it('sets user when fetch succeeds', async () => { const mockUser = { id: 'user-1', email: 'test@example.com', firstName: 'Test', lastName: 'User', }; vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.user).toEqual(mockUser); }); }); it('sets loading=false after fetch completes', async () => { vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: 'user-1', email: 'test@example.com', }); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); }); it('handles fetch error gracefully and clears user', async () => { vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.user).toBeNull(); }); }); it('redirects to login on AuthExpiredError', async () => { vi.mocked(authApi.getCurrentUser).mockRejectedValue(new AuthExpiredError()); renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); }); }); }); describe('signIn', () => { it('calls signIn API with credentials', async () => { const mockUser = { id: 'user-1', email: 'test@example.com' } as CurrentUser; vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); vi.mocked(authApi.signIn).mockResolvedValue(mockUser); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); await act(async () => { await result.current.signIn('test@example.com', 'password123'); }); expect(authApi.signIn).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); }); it('sets user on successful sign in', async () => { const mockUser = { id: 'user-1', email: 'test@example.com' } as CurrentUser; vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); vi.mocked(authApi.signIn).mockResolvedValue(mockUser); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); await act(async () => { await result.current.signIn('test@example.com', 'password123'); }); expect(result.current.user).toEqual(mockUser); }); it('returns error on sign in failure', async () => { vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); vi.mocked(authApi.signIn).mockRejectedValue(new Error('Invalid credentials')); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); let signInResult: { error: string | null } | undefined; await act(async () => { signInResult = await result.current.signIn('test@example.com', 'wrong'); }); expect(signInResult?.error).toBeTruthy(); }); it('sets loading during sign in request', async () => { vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); vi.mocked(authApi.signIn).mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve({ id: '1', email: 'test@example.com' } as CurrentUser), 100)), ); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); let signInPromise: Promise<{ error: string | null }>; act(() => { signInPromise = result.current.signIn('test@example.com', 'password'); }); expect(result.current.loading).toBe(true); await act(async () => { await signInPromise; }); expect(result.current.loading).toBe(false); }); }); describe('signOut', () => { it('calls signOut API', async () => { const mockUser = { id: 'user-1', email: 'test@example.com' }; vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); vi.mocked(authApi.signOut).mockResolvedValue(undefined); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.user).toEqual(mockUser); }); await act(async () => { await result.current.signOut(); }); expect(authApi.signOut).toHaveBeenCalledTimes(1); }); it('clears user on successful sign out', async () => { const mockUser = { id: 'user-1', email: 'test@example.com' }; vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); vi.mocked(authApi.signOut).mockResolvedValue(undefined); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.user).toEqual(mockUser); }); await act(async () => { await result.current.signOut(); }); expect(result.current.user).toBeNull(); }); it('handles AuthExpiredError gracefully', async () => { const mockUser = { id: 'user-1', email: 'test@example.com' }; vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); vi.mocked(authApi.signOut).mockRejectedValue(new AuthExpiredError()); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.user).toEqual(mockUser); }); mockNavigate.mockClear(); let signOutResult: { error: string | null } | undefined; await act(async () => { signOutResult = await result.current.signOut(); }); expect(signOutResult?.error).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); }); }); describe('isAuthenticated', () => { it('returns true when user is set', async () => { vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: 'user-1', email: 'test@example.com', }); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(true); }); }); it('returns false when user is null', async () => { vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); const { result } = renderHook(() => useAuthSession(), { wrapper }); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.isAuthenticated).toBe(false); }); }); }); describe('useAuthModalWorkflow', () => { const mockSignIn = vi.fn().mockResolvedValue({ error: null }); const mockSignUp = vi.fn().mockResolvedValue({ error: null }); const mockOnClose = vi.fn(); beforeEach(() => { vi.clearAllMocks(); }); it('initializes with signin mode', () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); expect(result.current.state.mode).toBe('signin'); }); it('initializes with empty draft', () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); expect(result.current.state.draft.email).toBe(''); expect(result.current.state.draft.password).toBe(''); expect(result.current.state.draft.fullName).toBe(''); }); it('updates draft when updateDraft is called', () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.updateDraft({ email: 'new@example.com' }); }); expect(result.current.state.draft.email).toBe('new@example.com'); }); it('switches mode when switchMode is called', () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.switchMode('signup'); }); expect(result.current.state.mode).toBe('signup'); }); it('calls signIn with email and password on handleSignIn', async () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.updateDraft({ email: 'test@example.com', password: 'secret' }); }); await act(async () => { await result.current.actions.handleSignIn(); }); expect(mockSignIn).toHaveBeenCalledWith('test@example.com', 'secret'); }); it('sets error when signIn fails', async () => { const failingSignIn = vi.fn().mockResolvedValue({ error: 'Invalid credentials' }); const { result } = renderHook( () => useAuthModalWorkflow({ signIn: failingSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.updateDraft({ email: 'test@example.com', password: 'wrong' }); }); await act(async () => { await result.current.actions.handleSignIn(); }); expect(result.current.state.error).toBe('Invalid credentials'); }); it('sets loading during signIn', async () => { const slowSignIn = vi.fn().mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve({ error: null }), 100)), ); const { result } = renderHook( () => useAuthModalWorkflow({ signIn: slowSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.updateDraft({ email: 'test@example.com', password: 'secret' }); }); let signInPromise: Promise; act(() => { signInPromise = result.current.actions.handleSignIn(); }); expect(result.current.state.loading).toBe(true); await act(async () => { await signInPromise; }); expect(result.current.state.loading).toBe(false); }); it('resets form on handleClose', () => { const { result } = renderHook( () => useAuthModalWorkflow({ signIn: mockSignIn, signUp: mockSignUp, onClose: mockOnClose, }), { wrapper }, ); act(() => { result.current.actions.updateDraft({ email: 'test@example.com' }); result.current.actions.switchMode('signup'); }); expect(result.current.state.draft.email).toBe('test@example.com'); expect(result.current.state.mode).toBe('signup'); act(() => { result.current.actions.handleClose(); }); expect(result.current.state.draft.email).toBe(''); expect(result.current.state.mode).toBe('signin'); expect(mockOnClose).toHaveBeenCalled(); }); });