40227-vm/frontend/src/business/auth/hooks.test.tsx
2026-06-12 06:55:35 +02:00

468 lines
13 KiB
TypeScript

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 <MemoryRouter>{children}</MemoryRouter>;
}
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<void>;
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();
});
});