- Add login page with email validation and invite code support - Add OTP verification page with 6-digit code input - Implement redirect logic based on user state - Add placeholder dashboard page - Add comprehensive test suite for login flow - Export api as default for cleaner imports - All tests passing (12/12) Features: - Email validation with proper error handling - Optional invite code input - OTP resend with 60s countdown - Auto-submit when all 6 digits entered - Loading states and error messages - Redirect to dashboard if already authenticated - Proper navigation flow after OTP verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { useRouter } from 'next/navigation';
|
|
import LoginPage from '../app/(auth)/login/page';
|
|
import api from '../lib/api';
|
|
|
|
// Mock Next.js router
|
|
jest.mock('next/navigation', () => ({
|
|
useRouter: jest.fn(),
|
|
useSearchParams: jest.fn(),
|
|
}));
|
|
|
|
// Mock API
|
|
jest.mock('../lib/api', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
getToken: jest.fn(),
|
|
checkEmail: jest.fn(),
|
|
requestOTP: jest.fn(),
|
|
saveEmail: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('LoginPage', () => {
|
|
const mockPush = jest.fn();
|
|
const mockReplace = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
(useRouter as jest.Mock).mockReturnValue({
|
|
push: mockPush,
|
|
replace: mockReplace,
|
|
});
|
|
(api.getToken as jest.Mock).mockResolvedValue(null);
|
|
});
|
|
|
|
it('renders login form correctly', () => {
|
|
render(<LoginPage />);
|
|
|
|
expect(screen.getByText('Welcome to WellNuo')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Email address')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /get verification code/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('redirects to dashboard if already authenticated', async () => {
|
|
(api.getToken as jest.Mock).mockResolvedValue('mock-token');
|
|
|
|
render(<LoginPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockReplace).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
});
|
|
|
|
it('shows error for empty email', async () => {
|
|
render(<LoginPage />);
|
|
|
|
const form = screen.getByRole('button', { name: /get verification code/i }).closest('form');
|
|
fireEvent.submit(form!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows error for invalid email format', async () => {
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const form = screen.getByRole('button', { name: /get verification code/i }).closest('form');
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
|
|
fireEvent.submit(form!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('validates correct email format', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } });
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({ ok: true });
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(api.checkEmail).toHaveBeenCalledWith('test@example.com');
|
|
expect(api.requestOTP).toHaveBeenCalledWith('test@example.com');
|
|
});
|
|
});
|
|
|
|
it('navigates to verify-otp on successful OTP request', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } });
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({ ok: true });
|
|
(api.saveEmail as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const form = screen.getByRole('button', { name: /get verification code/i }).closest('form');
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.submit(form!);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(
|
|
expect.stringMatching(/\/verify-otp\?email=test(%40|@)example\.com/)
|
|
);
|
|
});
|
|
});
|
|
|
|
it('shows loading state during OTP request', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } });
|
|
(api.requestOTP as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Sending code...')).toBeInTheDocument();
|
|
expect(submitButton).toBeDisabled();
|
|
});
|
|
});
|
|
|
|
it('shows error message on failed OTP request', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } });
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Failed to send OTP' },
|
|
});
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Failed to send OTP')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows invite code field when toggled', () => {
|
|
render(<LoginPage />);
|
|
|
|
const inviteToggle = screen.getByText('Have an invite code?');
|
|
fireEvent.click(inviteToggle);
|
|
|
|
expect(screen.getByLabelText('Invite code (optional)')).toBeInTheDocument();
|
|
});
|
|
|
|
it('includes invite code in navigation params when provided', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: false } });
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({ ok: true });
|
|
(api.saveEmail as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const inviteToggle = screen.getByText('Have an invite code?');
|
|
fireEvent.click(inviteToggle);
|
|
|
|
const inviteInput = screen.getByLabelText('Invite code (optional)');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
|
fireEvent.change(inviteInput, { target: { value: 'INVITE123' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(
|
|
expect.stringContaining('inviteCode=INVITE123')
|
|
);
|
|
});
|
|
});
|
|
|
|
it('handles network errors gracefully', async () => {
|
|
(api.checkEmail as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Network error. Please check your connection.')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('trims and lowercases email before submission', async () => {
|
|
(api.checkEmail as jest.Mock).mockResolvedValue({ ok: true, data: { exists: true } });
|
|
(api.requestOTP as jest.Mock).mockResolvedValue({ ok: true });
|
|
|
|
render(<LoginPage />);
|
|
|
|
const emailInput = screen.getByLabelText('Email address');
|
|
const submitButton = screen.getByRole('button', { name: /get verification code/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: ' TEST@EXAMPLE.COM ' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(api.checkEmail).toHaveBeenCalledWith('test@example.com');
|
|
expect(api.requestOTP).toHaveBeenCalledWith('test@example.com');
|
|
});
|
|
});
|
|
});
|