506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
/**
|
|
* Component tests for authentication forms
|
|
* Tests LoginForm and RegisterForm Svelte components
|
|
*/
|
|
|
|
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import LoginForm from '$lib/components/auth/LoginForm.svelte';
|
|
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
|
|
|
|
describe('LoginForm', () => {
|
|
describe('Rendering', () => {
|
|
it('renders email and password fields', () => {
|
|
render(LoginForm);
|
|
|
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders submit button with correct text', () => {
|
|
render(LoginForm);
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
expect(button).toBeInTheDocument();
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
|
|
it('shows loading state when isLoading prop is true', () => {
|
|
render(LoginForm, { props: { isLoading: true } });
|
|
|
|
const button = screen.getByRole('button');
|
|
expect(button).toBeDisabled();
|
|
expect(screen.getByText(/logging in/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('has proper autocomplete attributes', () => {
|
|
render(LoginForm);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
|
|
expect(emailInput).toHaveAttribute('autocomplete', 'email');
|
|
expect(passwordInput).toHaveAttribute('autocomplete', 'current-password');
|
|
});
|
|
});
|
|
|
|
describe('Validation', () => {
|
|
it('shows error when email is empty on submit', async () => {
|
|
render(LoginForm);
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when email is invalid', async () => {
|
|
render(LoginForm);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'invalid-email' } });
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/valid email address/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when password is empty on submit', async () => {
|
|
render(LoginForm);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('accepts valid email formats', async () => {
|
|
const validEmails = ['test@example.com', 'user+tag@domain.co.uk', 'first.last@example.com'];
|
|
|
|
for (const email of validEmails) {
|
|
const { unmount } = render(LoginForm);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
await fireEvent.input(emailInput, { target: { value: email } });
|
|
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'password123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
// Should not show email error
|
|
expect(screen.queryByText(/valid email address/i)).not.toBeInTheDocument();
|
|
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it('clears errors when form is corrected', async () => {
|
|
render(LoginForm);
|
|
|
|
// Submit with empty email
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
|
|
|
|
// Fix email
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
// Submit again
|
|
await fireEvent.click(button);
|
|
|
|
// Email error should be gone, but password error should appear
|
|
expect(screen.queryByText(/email is required/i)).not.toBeInTheDocument();
|
|
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Submission', () => {
|
|
it('dispatches submit event with correct data on valid form', async () => {
|
|
const { component } = render(LoginForm);
|
|
|
|
const submitHandler = vi.fn();
|
|
component.$on('submit', submitHandler);
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'TestPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
expect(submitHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const event = submitHandler.mock.calls[0][0];
|
|
expect(event.detail).toEqual({
|
|
email: 'test@example.com',
|
|
password: 'TestPassword123',
|
|
});
|
|
});
|
|
|
|
it('does not dispatch submit event when form is invalid', async () => {
|
|
const { component } = render(LoginForm);
|
|
|
|
const submitHandler = vi.fn();
|
|
component.$on('submit', submitHandler);
|
|
|
|
// Try to submit with empty fields
|
|
const button = screen.getByRole('button', { name: /login/i });
|
|
await fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(submitHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disables all inputs when loading', () => {
|
|
render(LoginForm, { props: { isLoading: true } });
|
|
|
|
const emailInput = screen.getByLabelText(/email/i);
|
|
const passwordInput = screen.getByLabelText(/password/i);
|
|
const button = screen.getByRole('button');
|
|
|
|
expect(emailInput).toBeDisabled();
|
|
expect(passwordInput).toBeDisabled();
|
|
expect(button).toBeDisabled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('RegisterForm', () => {
|
|
describe('Rendering', () => {
|
|
it('renders all required fields', () => {
|
|
render(RegisterForm);
|
|
|
|
expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders submit button with correct text', () => {
|
|
render(RegisterForm);
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
expect(button).toBeInTheDocument();
|
|
expect(button).not.toBeDisabled();
|
|
});
|
|
|
|
it('shows password requirements help text', () => {
|
|
render(RegisterForm);
|
|
|
|
expect(
|
|
screen.getByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows loading state when isLoading prop is true', () => {
|
|
render(RegisterForm, { props: { isLoading: true } });
|
|
|
|
const button = screen.getByRole('button');
|
|
expect(button).toBeDisabled();
|
|
expect(screen.getByText(/creating account/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('has proper autocomplete attributes', () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
|
|
expect(emailInput).toHaveAttribute('autocomplete', 'email');
|
|
expect(passwordInput).toHaveAttribute('autocomplete', 'new-password');
|
|
expect(confirmPasswordInput).toHaveAttribute('autocomplete', 'new-password');
|
|
});
|
|
});
|
|
|
|
describe('Email Validation', () => {
|
|
it('shows error when email is empty', async () => {
|
|
render(RegisterForm);
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when email is invalid', async () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'not-an-email' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/valid email address/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Password Strength Validation', () => {
|
|
it('shows error when password is too short', async () => {
|
|
render(RegisterForm);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'Test1' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when password lacks uppercase letter', async () => {
|
|
render(RegisterForm);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'testpassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/uppercase letter/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when password lacks lowercase letter', async () => {
|
|
render(RegisterForm);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'TESTPASSWORD123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/lowercase letter/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when password lacks number', async () => {
|
|
render(RegisterForm);
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'TestPassword' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/contain a number/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('accepts valid password meeting all requirements', async () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
// Should not show password strength errors
|
|
expect(screen.queryByText(/at least 8 characters/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/uppercase letter/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/lowercase letter/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/contain a number/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Password Confirmation Validation', () => {
|
|
it('shows error when confirm password is empty', async () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/confirm your password/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error when passwords do not match', async () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
await fireEvent.input(confirmPasswordInput, { target: { value: 'DifferentPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('accepts matching passwords', async () => {
|
|
render(RegisterForm);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
// Should not show confirmation error
|
|
expect(screen.queryByText(/passwords do not match/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Submission', () => {
|
|
it('dispatches submit event with correct data on valid form', async () => {
|
|
const { component } = render(RegisterForm);
|
|
|
|
const submitHandler = vi.fn();
|
|
component.$on('submit', submitHandler);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
expect(submitHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
const event = submitHandler.mock.calls[0][0];
|
|
expect(event.detail).toEqual({
|
|
email: 'test@example.com',
|
|
password: 'ValidPassword123',
|
|
});
|
|
});
|
|
|
|
it('does not include confirmPassword in submit event', async () => {
|
|
const { component } = render(RegisterForm);
|
|
|
|
const submitHandler = vi.fn();
|
|
component.$on('submit', submitHandler);
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
|
|
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
await fireEvent.input(confirmPasswordInput, { target: { value: 'ValidPassword123' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
expect(submitHandler).toHaveBeenCalled();
|
|
});
|
|
|
|
const event = submitHandler.mock.calls[0][0];
|
|
expect(event.detail).not.toHaveProperty('confirmPassword');
|
|
});
|
|
|
|
it('does not dispatch submit event when form is invalid', async () => {
|
|
const { component } = render(RegisterForm);
|
|
|
|
const submitHandler = vi.fn();
|
|
component.$on('submit', submitHandler);
|
|
|
|
// Try to submit with empty fields
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(submitHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disables all inputs when loading', () => {
|
|
render(RegisterForm, { props: { isLoading: true } });
|
|
|
|
const emailInput = screen.getByLabelText(/^email$/i);
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
const confirmPasswordInput = screen.getByLabelText(/confirm password/i);
|
|
const button = screen.getByRole('button');
|
|
|
|
expect(emailInput).toBeDisabled();
|
|
expect(passwordInput).toBeDisabled();
|
|
expect(confirmPasswordInput).toBeDisabled();
|
|
expect(button).toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe('User Experience', () => {
|
|
it('hides help text when password error is shown', async () => {
|
|
render(RegisterForm);
|
|
|
|
// Help text should be visible initially
|
|
expect(
|
|
screen.getByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
|
|
).toBeInTheDocument();
|
|
|
|
// Enter invalid password
|
|
const passwordInput = screen.getByLabelText(/^password$/i);
|
|
await fireEvent.input(passwordInput, { target: { value: 'short' } });
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
// Error should be shown
|
|
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
|
|
|
|
// Help text should be hidden
|
|
expect(
|
|
screen.queryByText(/must be 8\+ characters with uppercase, lowercase, and number/i)
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('validates all fields independently', async () => {
|
|
render(RegisterForm);
|
|
|
|
const button = screen.getByRole('button', { name: /create account/i });
|
|
await fireEvent.click(button);
|
|
|
|
// All errors should be shown
|
|
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/confirm your password/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|