1083 lines
38 KiB
TypeScript
1083 lines
38 KiB
TypeScript
/**
|
|
* Auth Feature Flow Tests
|
|
*
|
|
* These tests verify routing, guards, navigation, cross-screen state, and user flows
|
|
* for the auth module. They run with vitest and React Testing Library.
|
|
*
|
|
* Contracts are defined in apps/website/lib/types/generated
|
|
*
|
|
* @file apps/website/tests/flows/auth.test.tsx
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import React from 'react';
|
|
import { LoginClient } from '@/client-wrapper/LoginClient';
|
|
import { SignupClient } from '@/client-wrapper/SignupClient';
|
|
import { ForgotPasswordClient } from '@/client-wrapper/ForgotPasswordClient';
|
|
import { ResetPasswordClient } from '@/client-wrapper/ResetPasswordClient';
|
|
import type { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
|
import type { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
|
import type { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
|
import type { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
|
import { Result } from '@/lib/contracts/Result';
|
|
|
|
// Mock next/navigation
|
|
const mockPush = vi.fn();
|
|
const mockReplace = vi.fn();
|
|
const mockRefresh = vi.fn();
|
|
const mockSearchParams = new URLSearchParams();
|
|
|
|
// Mock window.location to prevent navigation errors
|
|
const originalLocation = window.location;
|
|
delete (window as any).location;
|
|
(window as any).location = {
|
|
href: '',
|
|
pathname: '/auth/login',
|
|
search: '',
|
|
hash: '',
|
|
origin: 'http://localhost:3000',
|
|
protocol: 'http:',
|
|
host: 'localhost:3000',
|
|
hostname: 'localhost',
|
|
port: '3000',
|
|
assign: vi.fn(),
|
|
replace: vi.fn(),
|
|
reload: vi.fn(),
|
|
};
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({
|
|
push: mockPush,
|
|
replace: mockReplace,
|
|
refresh: mockRefresh,
|
|
}),
|
|
useSearchParams: () => mockSearchParams,
|
|
usePathname: () => '/auth/login',
|
|
}));
|
|
|
|
// Mock AuthContext
|
|
const mockRefreshSession = vi.fn(() => Promise.resolve());
|
|
let mockSession: any = null;
|
|
|
|
vi.mock('@/components/auth/AuthContext', () => ({
|
|
useAuth: () => ({
|
|
refreshSession: mockRefreshSession,
|
|
session: mockSession,
|
|
}),
|
|
}));
|
|
|
|
// Mock mutations
|
|
const mockLoginMutation = {
|
|
execute: vi.fn(() => Promise.resolve(Result.ok({}))),
|
|
};
|
|
|
|
const mockSignupMutation = {
|
|
execute: vi.fn(() => Promise.resolve(Result.ok({}))),
|
|
};
|
|
|
|
const mockForgotPasswordMutation = {
|
|
execute: vi.fn(() => Promise.resolve(Result.ok({}))),
|
|
};
|
|
|
|
const mockResetPasswordMutation = {
|
|
execute: vi.fn(() => Promise.resolve(Result.ok({}))),
|
|
};
|
|
|
|
vi.mock('@/lib/mutations/auth/LoginMutation', () => ({
|
|
LoginMutation: vi.fn().mockImplementation(() => ({
|
|
execute: (...args: any[]) => mockLoginMutation.execute(...args),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@/lib/mutations/auth/SignupMutation', () => ({
|
|
SignupMutation: vi.fn().mockImplementation(() => ({
|
|
execute: (...args: any[]) => mockSignupMutation.execute(...args),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@/lib/mutations/auth/ForgotPasswordMutation', () => ({
|
|
ForgotPasswordMutation: vi.fn().mockImplementation(() => ({
|
|
execute: (...args: any[]) => mockForgotPasswordMutation.execute(...args),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@/lib/mutations/auth/ResetPasswordMutation', () => ({
|
|
ResetPasswordMutation: vi.fn().mockImplementation(() => ({
|
|
execute: (...args: any[]) => mockResetPasswordMutation.execute(...args),
|
|
})),
|
|
}));
|
|
|
|
// Mock process.env
|
|
const originalNodeEnv = process.env.NODE_ENV;
|
|
beforeEach(() => {
|
|
process.env.NODE_ENV = 'development';
|
|
vi.clearAllMocks();
|
|
mockSession = null;
|
|
mockSearchParams.delete('returnTo');
|
|
mockSearchParams.delete('token');
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.NODE_ENV = originalNodeEnv;
|
|
});
|
|
|
|
describe('Auth Feature Flow', () => {
|
|
describe('Login Flow', () => {
|
|
const mockLoginViewData: LoginViewData = {
|
|
formState: {
|
|
fields: {
|
|
email: { value: '', touched: false, error: undefined, validating: false },
|
|
password: { value: '', touched: false, error: undefined, validating: false },
|
|
rememberMe: { value: false, touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
showPassword: false,
|
|
showErrorDetails: false,
|
|
hasInsufficientPermissions: false,
|
|
returnTo: '/dashboard',
|
|
isSubmitting: false,
|
|
};
|
|
|
|
it('should display login form with all fields', () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
expect(screen.getByLabelText(/email address/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/password/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/keep me signed in/i)).toBeDefined();
|
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined();
|
|
});
|
|
|
|
it('should display validation errors for empty fields', async () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/email is required/i)).toBeDefined();
|
|
expect(screen.getByText(/password is required/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should display validation error for invalid email format', async () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
|
|
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/invalid email/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should successfully login with valid credentials', async () => {
|
|
mockLoginMutation.execute.mockImplementation(() => {
|
|
mockSession = { userId: 'user-123' };
|
|
return Promise.resolve(Result.ok({
|
|
token: 'test-token-123',
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
});
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoginMutation.execute).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
password: 'ValidPass123!',
|
|
rememberMe: false,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockRefreshSession).toHaveBeenCalled();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
});
|
|
|
|
it('should handle login with remember me option', async () => {
|
|
mockLoginMutation.execute.mockImplementation(() => {
|
|
mockSession = { userId: 'user-123' };
|
|
return Promise.resolve(Result.ok({
|
|
token: 'test-token-123',
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
});
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const rememberMeCheckbox = screen.getByLabelText(/keep me signed in/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(rememberMeCheckbox);
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoginMutation.execute).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
password: 'ValidPass123!',
|
|
rememberMe: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should handle login errors (invalid credentials)', async () => {
|
|
mockLoginMutation.execute.mockResolvedValue(
|
|
Result.err('Invalid credentials')
|
|
);
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'WrongPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/invalid credentials/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify form remains in error state
|
|
expect((emailInput as HTMLInputElement).value).toBe('wrong@example.com');
|
|
});
|
|
|
|
it('should handle login errors (server error)', async () => {
|
|
mockLoginMutation.execute.mockResolvedValue(
|
|
Result.err('Internal Server Error')
|
|
);
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/error/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should navigate to forgot password from login', () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const forgotPasswordLink = screen.getByText(/forgot password/i);
|
|
fireEvent.click(forgotPasswordLink);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/forgot-password');
|
|
});
|
|
|
|
it('should navigate to signup from login', () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const signupLink = screen.getByText(/create one/i);
|
|
fireEvent.click(signupLink);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/signup');
|
|
});
|
|
|
|
it('should handle password visibility toggle', () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
expect((passwordInput as HTMLInputElement).type).toBe('password');
|
|
|
|
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
|
fireEvent.click(toggleButton);
|
|
|
|
expect((passwordInput as HTMLInputElement).type).toBe('text');
|
|
});
|
|
});
|
|
|
|
describe('Signup Flow', () => {
|
|
const mockSignupViewData: SignupViewData = {
|
|
returnTo: '/onboarding',
|
|
formState: {
|
|
fields: {
|
|
firstName: { value: '', touched: false, error: undefined, validating: false },
|
|
lastName: { value: '', touched: false, error: undefined, validating: false },
|
|
email: { value: '', touched: false, error: undefined, validating: false },
|
|
password: { value: '', touched: false, error: undefined, validating: false },
|
|
confirmPassword: { value: '', touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
isSubmitting: false,
|
|
};
|
|
|
|
it('should display signup form with all fields', () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
expect(screen.getByLabelText(/first name/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/last name/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/email address/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/^password$/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/confirm password/i)).toBeDefined();
|
|
expect(screen.getByRole('button', { name: /create account/i })).toBeDefined();
|
|
});
|
|
|
|
it('should display validation errors for empty required fields', async () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create account/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/first name is required/i)).toBeDefined();
|
|
expect(screen.getByText(/last name is required/i)).toBeDefined();
|
|
expect(screen.getByText(/email is required/i)).toBeDefined();
|
|
expect(screen.getByText(/password is required/i)).toBeDefined();
|
|
expect(screen.getByText(/confirm password is required/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should display validation errors for weak password', async () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
fireEvent.change(passwordInput, { target: { value: 'weak' } });
|
|
|
|
const submitButton = screen.getByRole('button', { name: /create account/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password must be/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should successfully signup with valid data', async () => {
|
|
mockSignupMutation.execute.mockImplementation(() => {
|
|
mockSession = { userId: 'user-456' };
|
|
return Promise.resolve(Result.ok({
|
|
token: 'test-token-456',
|
|
user: {
|
|
userId: 'user-456',
|
|
email: 'newuser@example.com',
|
|
displayName: 'New User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
});
|
|
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const firstNameInput = screen.getByLabelText(/first name/i);
|
|
const lastNameInput = screen.getByLabelText(/last name/i);
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /create account/i });
|
|
|
|
fireEvent.change(firstNameInput, { target: { value: 'New' } });
|
|
fireEvent.change(lastNameInput, { target: { value: 'User' } });
|
|
fireEvent.change(emailInput, { target: { value: 'newuser@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockSignupMutation.execute).toHaveBeenCalledWith({
|
|
email: 'newuser@example.com',
|
|
password: 'ValidPass123!',
|
|
displayName: 'New User',
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockRefreshSession).toHaveBeenCalled();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/onboarding');
|
|
});
|
|
});
|
|
|
|
it('should handle signup errors (email already exists)', async () => {
|
|
mockSignupMutation.execute.mockResolvedValue(
|
|
Result.err('Email already exists')
|
|
);
|
|
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const firstNameInput = screen.getByLabelText(/first name/i);
|
|
const lastNameInput = screen.getByLabelText(/last name/i);
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /create account/i });
|
|
|
|
fireEvent.change(firstNameInput, { target: { value: 'Existing' } });
|
|
fireEvent.change(lastNameInput, { target: { value: 'User' } });
|
|
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/already exists/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should handle signup errors (server error)', async () => {
|
|
mockSignupMutation.execute.mockResolvedValue(
|
|
Result.err('Internal Server Error')
|
|
);
|
|
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const firstNameInput = screen.getByLabelText(/first name/i);
|
|
const lastNameInput = screen.getByLabelText(/last name/i);
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /create account/i });
|
|
|
|
fireEvent.change(firstNameInput, { target: { value: 'Test' } });
|
|
fireEvent.change(lastNameInput, { target: { value: 'User' } });
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/error/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should navigate to login from signup', () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const loginLink = screen.getByText(/already have an account/i);
|
|
fireEvent.click(loginLink);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
|
|
it('should handle password visibility toggle', () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
expect((passwordInput as HTMLInputElement).type).toBe('password');
|
|
|
|
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
|
fireEvent.click(toggleButton);
|
|
|
|
expect((passwordInput as HTMLInputElement).type).toBe('text');
|
|
});
|
|
|
|
it('should handle confirm password visibility toggle', () => {
|
|
render(<SignupClient viewData={mockSignupViewData} />);
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
expect((confirmPasswordInput as HTMLInputElement).type).toBe('password');
|
|
|
|
const toggleButton = screen.getByRole('button', { name: /show confirm password/i });
|
|
fireEvent.click(toggleButton);
|
|
|
|
expect((confirmPasswordInput as HTMLInputElement).type).toBe('text');
|
|
});
|
|
});
|
|
|
|
describe('Forgot Password Flow', () => {
|
|
const mockForgotPasswordViewData: ForgotPasswordViewData = {
|
|
returnTo: '/auth/login',
|
|
formState: {
|
|
fields: {
|
|
email: { value: '', touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
showSuccess: false,
|
|
successMessage: undefined,
|
|
magicLink: undefined,
|
|
isSubmitting: false,
|
|
};
|
|
|
|
it('should display forgot password form with email field', () => {
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
expect(screen.getByLabelText(/email address/i)).toBeDefined();
|
|
expect(screen.getByRole('button', { name: /send reset link/i })).toBeDefined();
|
|
});
|
|
|
|
it('should display validation error for empty email', async () => {
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const submitButton = screen.getByRole('button', { name: /send reset link/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/email is required/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should display validation error for invalid email format', async () => {
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
|
|
|
|
const submitButton = screen.getByRole('button', { name: /send reset link/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/invalid email/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should successfully submit forgot password request', async () => {
|
|
mockForgotPasswordMutation.execute.mockResolvedValue(
|
|
Result.ok({
|
|
success: true,
|
|
message: 'Password reset email sent',
|
|
})
|
|
);
|
|
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const submitButton = screen.getByRole('button', { name: /send reset link/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockForgotPasswordMutation.execute).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password reset email sent/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should handle forgot password errors (email not found)', async () => {
|
|
mockForgotPasswordMutation.execute.mockResolvedValue(
|
|
Result.err('Email not found')
|
|
);
|
|
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const submitButton = screen.getByRole('button', { name: /send reset link/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'nonexistent@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/email not found/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should handle forgot password errors (rate limit)', async () => {
|
|
mockForgotPasswordMutation.execute.mockResolvedValue(
|
|
Result.err('Rate limit exceeded')
|
|
);
|
|
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const submitButton = screen.getByRole('button', { name: /send reset link/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/rate limit/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should navigate back to login from forgot password', () => {
|
|
render(<ForgotPasswordClient viewData={mockForgotPasswordViewData} />);
|
|
|
|
const loginLink = screen.getByText(/back to login/i);
|
|
fireEvent.click(loginLink);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
});
|
|
|
|
describe('Reset Password Flow', () => {
|
|
const mockResetPasswordViewData: ResetPasswordViewData = {
|
|
token: 'abc123',
|
|
returnTo: '/auth/login',
|
|
formState: {
|
|
fields: {
|
|
newPassword: { value: '', touched: false, error: undefined, validating: false },
|
|
confirmPassword: { value: '', touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
showSuccess: false,
|
|
successMessage: undefined,
|
|
isSubmitting: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockSearchParams.set('token', 'abc123');
|
|
});
|
|
|
|
it('should display reset password form with password fields', () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
expect(screen.getByLabelText(/^new password$/i)).toBeDefined();
|
|
expect(screen.getByLabelText(/confirm password/i)).toBeDefined();
|
|
expect(screen.getByRole('button', { name: /reset password/i })).toBeDefined();
|
|
});
|
|
|
|
it('should display validation errors for empty password fields', async () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/new password is required/i)).toBeDefined();
|
|
expect(screen.getByText(/confirm password is required/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should display validation error for non-matching passwords', async () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
|
|
fireEvent.change(newPasswordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'DifferentPass456!' } });
|
|
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/passwords do not match/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should display validation error for weak new password', async () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
fireEvent.change(newPasswordInput, { target: { value: 'weak' } });
|
|
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password must be/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should successfully reset password', async () => {
|
|
mockResetPasswordMutation.execute.mockResolvedValue(
|
|
Result.ok({
|
|
success: true,
|
|
message: 'Password reset successfully',
|
|
})
|
|
);
|
|
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
|
|
fireEvent.change(newPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockResetPasswordMutation.execute).toHaveBeenCalledWith({
|
|
token: 'abc123',
|
|
newPassword: 'NewPass123!',
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/password reset successfully/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify redirect to login page after delay
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/login');
|
|
}, { timeout: 5000 });
|
|
});
|
|
|
|
it('should handle reset password with invalid token', async () => {
|
|
mockResetPasswordMutation.execute.mockResolvedValue(
|
|
Result.err('Invalid token')
|
|
);
|
|
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
|
|
fireEvent.change(newPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/invalid token/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify form is disabled
|
|
expect((submitButton as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it('should handle reset password with expired token', async () => {
|
|
mockResetPasswordMutation.execute.mockResolvedValue(
|
|
Result.err('Token expired')
|
|
);
|
|
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
|
|
fireEvent.change(newPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/token expired/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify link to request new reset email
|
|
expect(screen.getByText(/request new link/i)).toBeDefined();
|
|
});
|
|
|
|
it('should handle reset password errors (server error)', async () => {
|
|
mockResetPasswordMutation.execute.mockResolvedValue(
|
|
Result.err('Internal Server Error')
|
|
);
|
|
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const submitButton = screen.getByRole('button', { name: /reset password/i });
|
|
|
|
fireEvent.change(newPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.change(confirmPasswordInput, { target: { value: 'NewPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/error/i)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should navigate to login from reset password', () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const loginLink = screen.getByText(/back to login/i);
|
|
fireEvent.click(loginLink);
|
|
|
|
expect(mockPush).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
|
|
it('should handle password visibility toggle', () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const newPasswordInput = screen.getByLabelText(/^new password$/i);
|
|
expect((newPasswordInput as HTMLInputElement).type).toBe('password');
|
|
|
|
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
|
fireEvent.click(toggleButton);
|
|
|
|
expect((newPasswordInput as HTMLInputElement).type).toBe('text');
|
|
});
|
|
|
|
it('should handle confirm password visibility toggle', () => {
|
|
render(<ResetPasswordClient viewData={mockResetPasswordViewData} />);
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
expect((confirmPasswordInput as HTMLInputElement).type).toBe('password');
|
|
|
|
const toggleButton = screen.getByRole('button', { name: /show confirm password/i });
|
|
fireEvent.click(toggleButton);
|
|
|
|
expect((confirmPasswordInput as HTMLInputElement).type).toBe('text');
|
|
});
|
|
});
|
|
|
|
describe('Auth Cross-Screen State Management', () => {
|
|
const mockLoginViewData: LoginViewData = {
|
|
formState: {
|
|
fields: {
|
|
email: { value: '', touched: false, error: undefined, validating: false },
|
|
password: { value: '', touched: false, error: undefined, validating: false },
|
|
rememberMe: { value: false, touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
showPassword: false,
|
|
showErrorDetails: false,
|
|
hasInsufficientPermissions: false,
|
|
returnTo: '/dashboard',
|
|
isSubmitting: false,
|
|
};
|
|
|
|
it('should preserve form data when navigating between auth pages', async () => {
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
// Navigate to forgot password
|
|
const forgotPasswordLink = screen.getByText(/forgot password/i);
|
|
fireEvent.click(forgotPasswordLink);
|
|
|
|
// Navigate back to login
|
|
const loginLink = screen.getByText(/back to login/i);
|
|
fireEvent.click(loginLink);
|
|
|
|
// Verify email is preserved
|
|
await waitFor(() => {
|
|
expect((emailInput as HTMLInputElement).value).toBe('test@example.com');
|
|
});
|
|
});
|
|
|
|
it('should clear form data after successful authentication', async () => {
|
|
mockLoginMutation.execute.mockImplementation(() => {
|
|
mockSession = { userId: 'user-123' };
|
|
return Promise.resolve(Result.ok({
|
|
token: 'test-token-123',
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
});
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
|
|
// Navigate back to login
|
|
mockPush.mockClear();
|
|
const loginLink = screen.getByText(/sign in/i);
|
|
fireEvent.click(loginLink);
|
|
|
|
// Verify form is cleared
|
|
await waitFor(() => {
|
|
expect((emailInput as HTMLInputElement).value).toBe('');
|
|
expect((passwordInput as HTMLInputElement).value).toBe('');
|
|
});
|
|
});
|
|
|
|
it('should handle concurrent auth operations', async () => {
|
|
mockLoginMutation.execute.mockImplementation(() => {
|
|
mockSession = { userId: 'user-123' };
|
|
return Promise.resolve(Result.ok({
|
|
token: 'test-token-123',
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
});
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
|
|
// Click submit multiple times quickly
|
|
fireEvent.click(submitButton);
|
|
fireEvent.click(submitButton);
|
|
fireEvent.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoginMutation.execute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Auth UI State Management', () => {
|
|
const mockLoginViewData: LoginViewData = {
|
|
formState: {
|
|
fields: {
|
|
email: { value: '', touched: false, error: undefined, validating: false },
|
|
password: { value: '', touched: false, error: undefined, validating: false },
|
|
rememberMe: { value: false, touched: false, error: undefined, validating: false },
|
|
},
|
|
isValid: true,
|
|
isSubmitting: false,
|
|
submitCount: 0,
|
|
submitError: undefined,
|
|
},
|
|
showPassword: false,
|
|
showErrorDetails: false,
|
|
hasInsufficientPermissions: false,
|
|
returnTo: '/dashboard',
|
|
isSubmitting: false,
|
|
};
|
|
|
|
it('should show loading states during auth operations', async () => {
|
|
mockLoginMutation.execute.mockImplementation(
|
|
() => new Promise(resolve => setTimeout(() => {
|
|
mockSession = { userId: 'user-123' };
|
|
resolve(Result.ok({
|
|
token: 'test-token-123',
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: 'user',
|
|
},
|
|
}));
|
|
}, 100))
|
|
);
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Verify loading state
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/signing in/i)).toBeDefined();
|
|
});
|
|
|
|
// Wait for loading to complete
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/signing in/i)).toBeNull();
|
|
});
|
|
|
|
// Verify authentication is successful
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
});
|
|
|
|
it('should handle error states gracefully', async () => {
|
|
mockLoginMutation.execute.mockResolvedValue(
|
|
Result.err('Invalid credentials')
|
|
);
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'wrong@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'WrongPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Verify error message is displayed
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/invalid credentials/i)).toBeDefined();
|
|
});
|
|
|
|
// Verify UI remains usable after errors
|
|
expect((emailInput as HTMLInputElement).disabled).toBe(false);
|
|
expect((passwordInput as HTMLInputElement).disabled).toBe(false);
|
|
expect((submitButton as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('should handle network connectivity issues', async () => {
|
|
mockLoginMutation.execute.mockResolvedValue(
|
|
Result.err('Network Error')
|
|
);
|
|
|
|
render(<LoginClient viewData={mockLoginViewData} />);
|
|
|
|
const emailInput = screen.getByLabelText(/email address/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const submitButton = screen.getByRole('button', { name: /sign in/i });
|
|
|
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
|
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } });
|
|
fireEvent.click(submitButton);
|
|
|
|
// Verify network error message is shown
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/network/i)).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
});
|