468 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|