diff --git a/app/src/components/auth/LoginPage.test.tsx b/app/src/components/auth/LoginPage.test.tsx
new file mode 100644
index 0000000..5975b1d
--- /dev/null
+++ b/app/src/components/auth/LoginPage.test.tsx
@@ -0,0 +1,403 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ render as rtlRender,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react';
+import React from 'react';
+import { MantineProvider } from '@mantine/core';
+import { AuthProvider } from '@/contexts/AuthContext';
+import LoginPage from './LoginPage';
+
+// Mock the auth API functions
+const mockApiLogin = vi.fn();
+const mockApiLogout = vi.fn();
+const mockApiRefreshToken = vi.fn();
+const mockGetCurrentUser = vi.fn();
+
+vi.mock('@/api/auth', () => ({
+ login: (...args: unknown[]): unknown => mockApiLogin(...args),
+ logout: (...args: unknown[]): unknown => mockApiLogout(...args),
+ refreshToken: (...args: unknown[]): unknown => mockApiRefreshToken(...args),
+ getCurrentUser: (...args: unknown[]): unknown => mockGetCurrentUser(...args),
+}));
+
+// Mock notifications
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ },
+}));
+
+// Helper wrapper component for testing
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+// Custom render function
+const render = (ui: React.ReactElement) => {
+ return rtlRender(ui, { wrapper: TestWrapper });
+};
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup default mock implementations
+ mockGetCurrentUser.mockRejectedValue(new Error('No user session'));
+ mockApiLogin.mockResolvedValue({
+ id: 1,
+ email: 'test@example.com',
+ role: 'editor',
+ createdAt: '2024-01-01T00:00:00Z',
+ lastWorkspaceId: 1,
+ });
+ });
+
+ describe('Initial Render', () => {
+ it('renders the login form with all required elements', () => {
+ render();
+
+ // Check title and subtitle
+ expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
+ expect(
+ screen.getByText('Please sign in to continue')
+ ).toBeInTheDocument();
+
+ // Check form fields
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+
+ // Check submit button
+ expect(
+ screen.getByRole('button', { name: /sign in/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders form fields with correct placeholders', () => {
+ render();
+
+ const emailInput = screen.getByPlaceholderText('your@email.com');
+ const passwordInput = screen.getByPlaceholderText('Your password');
+
+ expect(emailInput).toBeInTheDocument();
+ expect(passwordInput).toBeInTheDocument();
+ });
+
+ it('renders required fields as required', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+
+ expect(emailInput).toBeRequired();
+ expect(passwordInput).toBeRequired();
+ });
+
+ it('submit button is not loading initially', () => {
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+ expect(submitButton).not.toHaveAttribute('data-loading', 'true');
+ });
+ });
+
+ describe('Form Interaction', () => {
+ it('updates email input value when typed', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+
+ expect((emailInput as HTMLInputElement).value).toBe('test@example.com');
+ });
+
+ it('updates password input value when typed', () => {
+ render();
+
+ const passwordInput = screen.getByLabelText(/password/i);
+ fireEvent.change(passwordInput, { target: { value: 'password123' } });
+
+ expect((passwordInput as HTMLInputElement).value).toBe('password123');
+ });
+
+ it('clears form values when inputs are cleared', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+
+ // Set values
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+ fireEvent.change(passwordInput, { target: { value: 'password123' } });
+
+ // Clear values
+ fireEvent.change(emailInput, { target: { value: '' } });
+ fireEvent.change(passwordInput, { target: { value: '' } });
+
+ expect((emailInput as HTMLInputElement).value).toBe('');
+ expect((passwordInput as HTMLInputElement).value).toBe('');
+ });
+ });
+
+ describe('Form Submission', () => {
+ it('calls login function with correct credentials on form submit', async () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Fill in the form
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+ fireEvent.change(passwordInput, { target: { value: 'password123' } });
+
+ // Submit the form
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalledWith(
+ 'test@example.com',
+ 'password123'
+ );
+ });
+ });
+
+ it('calls login function when form is submitted via Enter key', async () => {
+ render();
+
+ const form = screen.getByRole('form');
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+
+ // Fill in the form
+ fireEvent.change(emailInput, { target: { value: 'user@test.com' } });
+ fireEvent.change(passwordInput, { target: { value: 'testpass' } });
+
+ // Submit via form submission (Enter key)
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalledWith('user@test.com', 'testpass');
+ });
+ });
+
+ it('shows loading state during login process', async () => {
+ // Create a promise we can control
+ let resolveLogin: () => void;
+ const loginPromise = new Promise((resolve) => {
+ resolveLogin = resolve;
+ });
+ mockApiLogin.mockReturnValue(loginPromise);
+
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Fill in the form
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+ fireEvent.change(passwordInput, { target: { value: 'password123' } });
+
+ // Submit the form
+ fireEvent.click(submitButton);
+
+ // Check loading state
+ await waitFor(() => {
+ expect(submitButton).toHaveAttribute('data-loading', 'true');
+ });
+
+ // Resolve the login
+ resolveLogin!();
+
+ // Wait for loading to finish
+ await waitFor(() => {
+ expect(submitButton).not.toHaveAttribute('data-loading', 'true');
+ });
+ });
+
+ it('handles login errors gracefully', async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ mockApiLogin.mockRejectedValue(new Error('Login failed'));
+
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Fill in the form
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+ fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
+
+ // Submit the form
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalledWith(
+ 'test@example.com',
+ 'wrongpassword'
+ );
+ });
+
+ // Wait for error handling
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Login failed:',
+ expect.any(Error)
+ );
+ });
+
+ // Loading state should be reset
+ await waitFor(() => {
+ expect(submitButton).not.toHaveAttribute('data-loading', 'true');
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('prevents form submission with empty fields', () => {
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Try to submit without filling fields
+ fireEvent.click(submitButton);
+
+ // Login should not be called due to HTML5 validation
+ expect(mockApiLogin).not.toHaveBeenCalled();
+ });
+
+ it('prevents form submission with only email filled', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Fill only email
+ fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
+
+ // Try to submit
+ fireEvent.click(submitButton);
+
+ // Login should not be called due to HTML5 validation
+ expect(mockApiLogin).not.toHaveBeenCalled();
+ });
+
+ it('prevents form submission with only password filled', () => {
+ render();
+
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ // Fill only password
+ fireEvent.change(passwordInput, { target: { value: 'password123' } });
+
+ // Try to submit
+ fireEvent.click(submitButton);
+
+ // Login should not be called due to HTML5 validation
+ expect(mockApiLogin).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles special characters in email and password', async () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ const specialEmail = 'user+test@example-domain.com';
+ const specialPassword = 'P@ssw0rd!#$%';
+
+ fireEvent.change(emailInput, { target: { value: specialEmail } });
+ fireEvent.change(passwordInput, { target: { value: specialPassword } });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalledWith(
+ specialEmail,
+ specialPassword
+ );
+ });
+ });
+
+ it('handles very long email and password values', async () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+
+ const longEmail = 'a'.repeat(100) + '@example.com';
+ const longPassword = 'p'.repeat(200);
+
+ fireEvent.change(emailInput, { target: { value: longEmail } });
+ fireEvent.change(passwordInput, { target: { value: longPassword } });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalledWith(longEmail, longPassword);
+ });
+ });
+
+ it('resets loading state after successful login', async () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/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: 'password123' } });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockApiLogin).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(submitButton).not.toHaveAttribute('data-loading', 'true');
+ });
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('has proper form structure with labels', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+
+ expect(emailInput).toBeInTheDocument();
+ expect(passwordInput).toBeInTheDocument();
+ expect(emailInput.tagName).toBe('INPUT');
+ expect(passwordInput.tagName).toBe('INPUT');
+ });
+
+ it('has proper input types', () => {
+ render();
+
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+
+ expect(emailInput).toHaveAttribute('type', 'email');
+ expect(passwordInput).toHaveAttribute('type', 'password');
+ });
+
+ it('submit button has proper type', () => {
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /sign in/i });
+ expect(submitButton).toHaveAttribute('type', 'submit');
+ });
+ });
+});
diff --git a/app/src/components/auth/LoginPage.tsx b/app/src/components/auth/LoginPage.tsx
index ad45793..daa47a3 100644
--- a/app/src/components/auth/LoginPage.tsx
+++ b/app/src/components/auth/LoginPage.tsx
@@ -37,9 +37,10 @@ const LoginPage: React.FC = () => {
-