diff --git a/nsc-events-nextjs/jest.setup.js b/nsc-events-nextjs/jest.setup.js index 010b0b5..28c79e7 100644 --- a/nsc-events-nextjs/jest.setup.js +++ b/nsc-events-nextjs/jest.setup.js @@ -1 +1,5 @@ -import '@testing-library/jest-dom' \ No newline at end of file +import '@testing-library/jest-dom' + +// Set environment variables for tests +// This must be in jest.setup.js because some components read process.env at module load time +process.env.NSC_EVENTS_PUBLIC_API_URL = 'http://localhost:3001'; \ No newline at end of file diff --git a/nsc-events-nextjs/tests/unit/LoginWindow.test.tsx b/nsc-events-nextjs/tests/unit/LoginWindow.test.tsx new file mode 100644 index 0000000..f67d78b --- /dev/null +++ b/nsc-events-nextjs/tests/unit/LoginWindow.test.tsx @@ -0,0 +1,1058 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import '@testing-library/jest-dom'; + +// Set environment variable before importing component (matches pattern in queries.test.ts) +process.env.NSC_EVENTS_PUBLIC_API_URL = 'http://localhost:3001'; + +import LoginWindow from '@/components/LoginWindow'; + +// Mock next/navigation +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: jest.fn(), + prefetch: jest.fn(), + }), +})); + +// Mock next/image +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => { + // eslint-disable-next-line @next/next/no-img-element + return {props.alt}; + }, +})); + +// Helper to create a valid JWT token +const createMockJWT = (payload: object): string => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = btoa(JSON.stringify(payload)); + const signature = 'mock-signature'; + return `${header}.${body}.${signature}`; +}; + +describe('LoginWindow Component', () => { + const theme = createTheme(); + + // Store original console methods + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + const renderLoginWindow = () => { + return render( + + + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock localStorage + const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + }; + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + + // Mock window.dispatchEvent + jest.spyOn(window, 'dispatchEvent').mockImplementation(() => true); + + // Suppress console logs during tests + console.log = jest.fn(); + console.error = jest.fn(); + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + cleanup(); + }); + + describe('Rendering', () => { + it('should render the login form with all required elements', () => { + renderLoginWindow(); + + // Check for heading + expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument(); + + // Check for email input + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + + // Check for password input by id + expect(screen.getByRole('textbox', { name: /email address/i })).toBeInTheDocument(); + expect(document.getElementById('password')).toBeInTheDocument(); + + // Check for login button + expect(screen.getByRole('button', { name: /^login$/i })).toBeInTheDocument(); + + // Check for forgot password link + expect(screen.getByRole('link', { name: /forgot password/i })).toBeInTheDocument(); + + // Check for sign up link + expect(screen.getByRole('link', { name: /sign up/i })).toBeInTheDocument(); + + // Check for download app button + expect(screen.getByRole('button', { name: /download app/i })).toBeInTheDocument(); + }); + + it('should render email and password section labels', () => { + renderLoginWindow(); + + // Use getAllByText since there are multiple elements with these texts + const emailLabels = screen.getAllByText('Email'); + const passwordLabels = screen.getAllByText('Password'); + + expect(emailLabels.length).toBeGreaterThan(0); + expect(passwordLabels.length).toBeGreaterThan(0); + }); + + it('should render forgot password link with correct href', () => { + renderLoginWindow(); + + const forgotPasswordLink = screen.getByRole('link', { name: /forgot password/i }); + expect(forgotPasswordLink).toHaveAttribute('href', '/auth/forgot-password'); + }); + + it('should render sign up link with correct href', () => { + renderLoginWindow(); + + const signUpLink = screen.getByRole('link', { name: /sign up/i }); + expect(signUpLink).toHaveAttribute('href', '/auth/sign-up'); + }); + + it('should render Google Play image in download button', () => { + renderLoginWindow(); + + const googlePlayImage = screen.getByAltText('google_play'); + expect(googlePlayImage).toBeInTheDocument(); + expect(googlePlayImage).toHaveAttribute('src', '/images/google_play.png'); + }); + }); + + describe('Form State Management', () => { + it('should initialize with empty email and password fields', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + expect(emailInput.value).toBe(''); + expect(passwordInput.value).toBe(''); + }); + + it('should update email state when user types in email field', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + + expect(emailInput.value).toBe('test@example.com'); + }); + + it('should update password state when user types in password field', () => { + renderLoginWindow(); + + const passwordInput = document.getElementById('password') as HTMLInputElement; + fireEvent.change(passwordInput, { target: { value: 'secretpassword123' } }); + + expect(passwordInput.value).toBe('secretpassword123'); + }); + + it('should update both email and password independently', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + fireEvent.change(emailInput, { target: { value: 'user@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + + expect(emailInput.value).toBe('user@test.com'); + expect(passwordInput.value).toBe('mypassword'); + }); + + it('should preserve email when password is updated', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + fireEvent.change(emailInput, { target: { value: 'first@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass1' } }); + fireEvent.change(passwordInput, { target: { value: 'pass2' } }); + + expect(emailInput.value).toBe('first@test.com'); + expect(passwordInput.value).toBe('pass2'); + }); + + it('should preserve password when email is updated', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + fireEvent.change(passwordInput, { target: { value: 'mypassword' } }); + fireEvent.change(emailInput, { target: { value: 'new@test.com' } }); + + expect(emailInput.value).toBe('new@test.com'); + expect(passwordInput.value).toBe('mypassword'); + }); + + it('should handle special characters in email', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: 'user+tag@example.com' } }); + + expect(emailInput.value).toBe('user+tag@example.com'); + }); + + it('should handle special characters in password', () => { + renderLoginWindow(); + + const passwordInput = document.getElementById('password') as HTMLInputElement; + fireEvent.change(passwordInput, { target: { value: 'P@$$w0rd!#%&*' } }); + + expect(passwordInput.value).toBe('P@$$w0rd!#%&*'); + }); + }); + + describe('Form Submission', () => { + it('should call fetch with correct URL and payload on form submission', async () => { + const mockToken = createMockJWT({ role: 'user', email: 'test@example.com' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/auth/login'), + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123', + }), + }) + ); + }); + }); + + it('should prevent default form submission behavior', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const form = screen.getByRole('button', { name: /^login$/i }).closest('form'); + const submitEvent = new Event('submit', { bubbles: true, cancelable: true }); + const preventDefaultSpy = jest.spyOn(submitEvent, 'preventDefault'); + + fireEvent(form!, submitEvent); + + // The component should handle the event + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + describe('JWT Token Parsing', () => { + it('should correctly parse JWT token and extract admin role', async () => { + const mockToken = createMockJWT({ role: 'admin', email: 'admin@example.com' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'admin@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'adminpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/admin'); + }); + }); + + it('should correctly parse JWT token and extract creator role', async () => { + const mockToken = createMockJWT({ role: 'creator', email: 'creator@example.com' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'creator@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'creatorpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/creator'); + }); + }); + + it('should correctly parse JWT token and extract user role', async () => { + const mockToken = createMockJWT({ role: 'user', email: 'user@example.com' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'user@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'userpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('should handle invalid JWT token format', async () => { + const invalidToken = 'invalid-token-format'; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: invalidToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid token format')).toBeInTheDocument(); + }); + }); + + it('should handle JWT token with malformed base64 payload', async () => { + const malformedToken = 'header.!!!invalid-base64!!!.signature'; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: malformedToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid token format')).toBeInTheDocument(); + }); + }); + + it('should handle JWT token with invalid JSON in payload', async () => { + const header = btoa(JSON.stringify({ alg: 'HS256' })); + const invalidJsonPayload = btoa('not valid json'); + const invalidToken = `${header}.${invalidJsonPayload}.signature`; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: invalidToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid token format')).toBeInTheDocument(); + }); + }); + }); + + describe('Role-Based Navigation', () => { + it('should redirect admin users to /admin', async () => { + const mockToken = createMockJWT({ role: 'admin' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'admin@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'adminpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/admin'); + }); + }); + + it('should redirect creator users to /creator', async () => { + const mockToken = createMockJWT({ role: 'creator' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'creator@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'creatorpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/creator'); + }); + }); + + it('should redirect regular users to home page', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'user@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'userpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('should redirect to home page for unknown roles', async () => { + const mockToken = createMockJWT({ role: 'unknown_role' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'unknown@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('should redirect to home page when role is undefined', async () => { + const mockToken = createMockJWT({ email: 'user@test.com' }); // No role field + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'norole@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + // With no role, should redirect to home + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + }); + + describe('Error Handling', () => { + it('should display error message for invalid credentials (401/403)', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: 'Invalid credentials' }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'wrong@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'wrongpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid email or password')).toBeInTheDocument(); + }); + }); + + it('should display error when login response is missing token', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success but no token' }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Login response missing token')).toBeInTheDocument(); + }); + }); + + it('should display error when fetch throws an exception', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('An error occurred during login')).toBeInTheDocument(); + }); + }); + + it('should log error to console when fetch fails', async () => { + const networkError = new Error('Network error'); + global.fetch = jest.fn().mockRejectedValue(networkError); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(console.error).toHaveBeenCalledWith('Login error:', networkError); + }); + }); + + it('should display error for server error (500)', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid email or password')).toBeInTheDocument(); + }); + }); + + it('should not display error message initially', () => { + renderLoginWindow(); + + // Ensure no error message is shown on initial render + expect(screen.queryByText('Invalid email or password')).not.toBeInTheDocument(); + expect(screen.queryByText('Login response missing token')).not.toBeInTheDocument(); + expect(screen.queryByText('Invalid token format')).not.toBeInTheDocument(); + expect(screen.queryByText('An error occurred during login')).not.toBeInTheDocument(); + }); + + it('should clear previous error on new submission attempt', async () => { + // First submission fails + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'wrong@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'wrongpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid email or password')).toBeInTheDocument(); + }); + + // Second submission succeeds + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + fireEvent.change(emailInput, { target: { value: 'correct@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'correctpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + }); + + describe('LocalStorage Token Persistence', () => { + it('should store token in localStorage on successful login', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(localStorage.setItem).toHaveBeenCalledWith('token', mockToken); + }); + }); + + it('should not store token when login fails', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'wrongpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid email or password')).toBeInTheDocument(); + }); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should not store token when response is missing token', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: 'No token' }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Login response missing token')).toBeInTheDocument(); + }); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should not store token when token format is invalid', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: 'invalid-token' }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText('Invalid token format')).toBeInTheDocument(); + }); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should dispatch auth-change event after storing token', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(window.dispatchEvent).toHaveBeenCalled(); + const dispatchedEvent = (window.dispatchEvent as jest.Mock).mock.calls[0][0]; + expect(dispatchedEvent.type).toBe('auth-change'); + }); + }); + + it('should store token before dispatching auth-change event', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + const callOrder: string[] = []; + (localStorage.setItem as jest.Mock).mockImplementation(() => { + callOrder.push('setItem'); + }); + (window.dispatchEvent as jest.Mock).mockImplementation(() => { + callOrder.push('dispatchEvent'); + return true; + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(callOrder).toEqual(['setItem', 'dispatchEvent']); + }); + }); + }); + + describe('Theme Integration', () => { + it('should render correctly with light theme', () => { + const lightTheme = createTheme({ palette: { mode: 'light' } }); + + render( + + + + ); + + expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument(); + }); + + it('should render correctly with dark theme', () => { + const darkTheme = createTheme({ palette: { mode: 'dark' } }); + + render( + + + + ); + + expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have accessible email input with label', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + expect(emailInput).toHaveAttribute('id', 'email'); + expect(emailInput).toHaveAttribute('name', 'email'); + expect(emailInput).toHaveAttribute('autocomplete', 'email'); + }); + + it('should have accessible password input with label', () => { + renderLoginWindow(); + + const passwordInput = document.getElementById('password') as HTMLInputElement; + expect(passwordInput).toHaveAttribute('id', 'password'); + expect(passwordInput).toHaveAttribute('name', 'password'); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAttribute('autocomplete', 'current-password'); + }); + + it('should have submit button with correct type', () => { + renderLoginWindow(); + + const loginButton = screen.getByRole('button', { name: /^login$/i }); + expect(loginButton).toHaveAttribute('type', 'submit'); + }); + + it('should mark inputs as required', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + + expect(emailInput).toHaveAttribute('required'); + expect(passwordInput).toHaveAttribute('required'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty form submission', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + }); + + renderLoginWindow(); + + const loginButton = screen.getByRole('button', { name: /^login$/i }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + }); + + it('should handle whitespace-only input', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: ' ' } }); + fireEvent.change(passwordInput, { target: { value: ' ' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + email: ' ', + password: ' ', + }), + }) + ); + }); + }); + + it('should handle very long email input', () => { + const longEmail = 'a'.repeat(100) + '@' + 'b'.repeat(100) + '.com'; + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: longEmail } }); + + expect(emailInput.value).toBe(longEmail); + }); + + it('should handle very long password input', () => { + const longPassword = 'p'.repeat(1000); + + renderLoginWindow(); + + const passwordInput = document.getElementById('password') as HTMLInputElement as HTMLInputElement; + fireEvent.change(passwordInput, { target: { value: longPassword } }); + + expect(passwordInput.value).toBe(longPassword); + }); + + it('should handle unicode characters in inputs', () => { + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i) as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement as HTMLInputElement; + + fireEvent.change(emailInput, { target: { value: 'user@example.jp' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + expect(emailInput.value).toBe('user@example.jp'); + expect(passwordInput.value).toBe('password123'); + }); + + it('should handle rapid consecutive submissions', async () => { + const mockToken = createMockJWT({ role: 'user' }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + + // Rapid clicks + fireEvent.click(loginButton); + fireEvent.click(loginButton); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + }); + + it('should handle token with additional payload fields', async () => { + const mockToken = createMockJWT({ + role: 'admin', + email: 'admin@test.com', + iat: 1234567890, + exp: 1234567890 + 3600, + sub: 'user-id-123', + permissions: ['read', 'write', 'delete'], + }); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'admin@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'adminpass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/admin'); + }); + }); + + it('should handle token with empty payload', async () => { + const mockToken = createMockJWT({}); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: mockToken }), + }); + + renderLoginWindow(); + + const emailInput = screen.getByLabelText(/email address/i); + const passwordInput = document.getElementById('password') as HTMLInputElement; + const loginButton = screen.getByRole('button', { name: /^login$/i }); + + fireEvent.change(emailInput, { target: { value: 'test@test.com' } }); + fireEvent.change(passwordInput, { target: { value: 'pass' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + // With no role, should redirect to home + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + }); +});