/** * Unit tests for VerifyEmail component * * Tests all verification functionality including: * - Loading state while verifying * - Success message on verification * - Error state for invalid token * - Redirect after success * - Already verified state * - Missing token handling * - Navigation flows */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import VerifyEmail from '../VerifyEmail'; import apiClient from '../../api/client'; // Mock the API client vi.mock('../../api/client'); // Mock the cookies utility vi.mock('../../utils/cookies', () => ({ deleteCookie: vi.fn(), })); // Mock the domain utility vi.mock('../../utils/domain', () => ({ getBaseDomain: vi.fn(() => 'lvh.me'), })); // Helper to render component with router and search params const renderWithRouter = (searchParams: string = '') => { const TestComponent = () => ( } /> Login Page} /> ); return render(); }; describe('VerifyEmail', () => { beforeEach(() => { vi.clearAllMocks(); // Reset location mock delete (window as any).location; (window as any).location = { protocol: 'http:', hostname: 'platform.lvh.me', port: '5173', href: 'http://platform.lvh.me:5173/verify-email', }; }); afterEach(() => { vi.resetAllMocks(); }); describe('Missing Token Handling', () => { it('should show error when no token is provided', () => { renderWithRouter(''); expect(screen.getByText('Invalid Link')).toBeInTheDocument(); expect( screen.getByText('No verification token was provided. Please check your email for the correct link.') ).toBeInTheDocument(); }); it('should render "Go to Login" button when token is missing', () => { renderWithRouter(''); const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); it('should navigate to login when button clicked with missing token', async () => { renderWithRouter(''); const loginButton = screen.getByRole('button', { name: /go to login/i }); fireEvent.click(loginButton); // Wait for navigation to complete await waitFor(() => { expect(screen.getByText('Login Page')).toBeInTheDocument(); }); }); it('should show error icon for missing token', () => { const { container } = renderWithRouter(''); // Check for red error styling const errorIcon = container.querySelector('.bg-red-100'); expect(errorIcon).toBeInTheDocument(); }); }); describe('Pending State', () => { it('should show pending state with verify button when token is present', () => { renderWithRouter('?token=valid-token-123'); expect(screen.getByText('Verify Your Email')).toBeInTheDocument(); expect( screen.getByText('Click the button below to confirm your email address.') ).toBeInTheDocument(); expect(screen.getByRole('button', { name: /confirm verification/i })).toBeInTheDocument(); }); it('should show shield icon in pending state', () => { const { container } = renderWithRouter('?token=valid-token-123'); // Check for brand color styling const iconContainer = container.querySelector('.bg-brand-100'); expect(iconContainer).toBeInTheDocument(); }); }); describe('Loading State', () => { it('should show loading state while verifying', async () => { // Mock API to never resolve (simulating slow response) const mockPost = vi.mocked(apiClient.post); mockPost.mockImplementation(() => new Promise(() => {})); renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verifying Your Email')).toBeInTheDocument(); }); expect( screen.getByText('Please wait while we verify your email address...') ).toBeInTheDocument(); }); it('should show loading spinner during verification', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockImplementation(() => new Promise(() => {})); const { container } = renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { const spinner = container.querySelector('.animate-spin'); expect(spinner).toBeInTheDocument(); }); }); it('should call API with correct token', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=test-token-456'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(mockPost).toHaveBeenCalledWith('/auth/email/verify/', { token: 'test-token-456' }); }); }); }); describe('Success State', () => { it('should show success message after successful verification', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); expect( screen.getByText( 'Your email address has been successfully verified. You can now sign in to your account.' ) ).toBeInTheDocument(); }); it('should show success icon after verification', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); const { container } = renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { const successIcon = container.querySelector('.bg-green-100'); expect(successIcon).toBeInTheDocument(); }); }); it('should clear auth cookies after successful verification', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); const { deleteCookie } = await import('../../utils/cookies'); renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(deleteCookie).toHaveBeenCalledWith('access_token'); expect(deleteCookie).toHaveBeenCalledWith('refresh_token'); }); }); it('should redirect to login page on success button click', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); const loginButton = screen.getByRole('button', { name: /go to login/i }); // Mock window.location.href to track the redirect let redirectUrl = ''; Object.defineProperty((window as any).location, 'href', { set: (url: string) => { redirectUrl = url; }, get: () => redirectUrl || 'http://platform.lvh.me:5173/verify-email', }); fireEvent.click(loginButton); // Verify the redirect URL was set correctly expect(redirectUrl).toBe('http://lvh.me:5173/login'); }); it('should build correct redirect URL with base domain', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=valid-token-123'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); const loginButton = screen.getByRole('button', { name: /go to login/i }); // Mock window.location.href to track the redirect let redirectUrl = ''; Object.defineProperty((window as any).location, 'href', { set: (url: string) => { redirectUrl = url; }, get: () => redirectUrl || 'http://platform.lvh.me:5173/verify-email', }); fireEvent.click(loginButton); // Verify the redirect URL uses the base domain expect(redirectUrl).toContain('lvh.me'); expect(redirectUrl).toContain('/login'); }); }); describe('Already Verified State', () => { it('should show already verified message when email is already verified', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } }); renderWithRouter('?token=already-verified-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Already Verified')).toBeInTheDocument(); }); expect( screen.getByText( 'This email address has already been verified. You can use it to sign in to your account.' ) ).toBeInTheDocument(); }); it('should show mail icon for already verified state', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } }); const { container } = renderWithRouter('?token=already-verified-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { const iconContainer = container.querySelector('.bg-blue-100'); expect(iconContainer).toBeInTheDocument(); }); }); it('should navigate to login from already verified state', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } }); renderWithRouter('?token=already-verified-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Already Verified')).toBeInTheDocument(); }); const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); }); describe('Error State', () => { it('should show error message for invalid token', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: { error: 'Invalid verification token', }, }, }); renderWithRouter('?token=invalid-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verification Failed')).toBeInTheDocument(); }); expect(screen.getByText('Invalid verification token')).toBeInTheDocument(); }); it('should show default error message when no error detail provided', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: {}, }, }); renderWithRouter('?token=invalid-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verification Failed')).toBeInTheDocument(); }); expect(screen.getByText('Failed to verify email')).toBeInTheDocument(); }); it('should show error icon for failed verification', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: { error: 'Token expired', }, }, }); const { container } = renderWithRouter('?token=expired-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { const errorIcon = container.querySelector('.bg-red-100'); expect(errorIcon).toBeInTheDocument(); }); }); it('should show helpful message about requesting new link', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: { error: 'Token expired', }, }, }); renderWithRouter('?token=expired-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect( screen.getByText( 'If you need a new verification link, please sign in and request one from your profile settings.' ) ).toBeInTheDocument(); }); }); it('should navigate to login from error state', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: { error: 'Token expired', }, }, }); renderWithRouter('?token=expired-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verification Failed')).toBeInTheDocument(); }); const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); it('should handle network errors gracefully', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue(new Error('Network error')); renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verification Failed')).toBeInTheDocument(); }); expect(screen.getByText('Failed to verify email')).toBeInTheDocument(); }); }); describe('Edge Cases', () => { it('should handle empty token parameter', () => { renderWithRouter('?token='); expect(screen.getByText('Invalid Link')).toBeInTheDocument(); }); it('should handle multiple query parameters', () => { renderWithRouter('?token=test-token&utm_source=email&extra=param'); expect(screen.getByText('Verify Your Email')).toBeInTheDocument(); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); expect(verifyButton).toBeInTheDocument(); }); it('should handle very long tokens', async () => { const longToken = 'a'.repeat(500); const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter(`?token=${longToken}`); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(mockPost).toHaveBeenCalledWith('/auth/email/verify/', { token: longToken }); }); }); it('should handle special characters in token', async () => { const specialToken = 'token-with-special_chars.123+abc=xyz'; const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter(`?token=${encodeURIComponent(specialToken)}`); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(mockPost).toHaveBeenCalled(); }); }); it('should not allow multiple simultaneous verification attempts', async () => { const mockPost = vi.mocked(apiClient.post); let resolvePromise: (value: any) => void; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockPost.mockReturnValue(pendingPromise as any); renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); // Click multiple times fireEvent.click(verifyButton); fireEvent.click(verifyButton); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Verifying Your Email')).toBeInTheDocument(); }); // API should only be called once expect(mockPost).toHaveBeenCalledTimes(1); // Resolve the promise resolvePromise!({ data: { detail: 'Email verified successfully.' } }); }); }); describe('Redirect URL Construction', () => { it('should use correct protocol in redirect URL', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); (window as any).location.protocol = 'https:'; renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); // Protocol should be used in redirect construction expect((window as any).location.protocol).toBe('https:'); }); it('should include port in redirect URL when present', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); (window as any).location.port = '3000'; renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); expect((window as any).location.port).toBe('3000'); }); it('should handle empty port correctly', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); (window as any).location.port = ''; renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); expect((window as any).location.port).toBe(''); }); }); describe('Complete User Flows', () => { it('should support complete successful verification flow', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); const { deleteCookie } = await import('../../utils/cookies'); renderWithRouter('?token=complete-flow-token'); // Initial state - show verify button expect(screen.getByText('Verify Your Email')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /confirm verification/i })).toBeInTheDocument(); // User clicks verify const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); // Loading state await waitFor(() => { expect(screen.getByText('Verifying Your Email')).toBeInTheDocument(); }); // Success state await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); // Verify cookies were cleared expect(deleteCookie).toHaveBeenCalledWith('access_token'); expect(deleteCookie).toHaveBeenCalledWith('refresh_token'); // User can navigate to login const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); it('should support complete failed verification flow', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockRejectedValue({ response: { data: { error: 'Verification token has expired', }, }, }); renderWithRouter('?token=expired-flow-token'); // Initial state expect(screen.getByText('Verify Your Email')).toBeInTheDocument(); // User clicks verify const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); // Loading state await waitFor(() => { expect(screen.getByText('Verifying Your Email')).toBeInTheDocument(); }); // Error state await waitFor(() => { expect(screen.getByText('Verification Failed')).toBeInTheDocument(); }); expect(screen.getByText('Verification token has expired')).toBeInTheDocument(); // User can navigate to login const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); it('should support already verified user flow', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email already verified.' } }); renderWithRouter('?token=already-verified-flow'); // User clicks verify const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); // Already verified state await waitFor(() => { expect(screen.getByText('Already Verified')).toBeInTheDocument(); }); expect( screen.getByText( 'This email address has already been verified. You can use it to sign in to your account.' ) ).toBeInTheDocument(); // User can navigate to login const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); }); describe('Accessibility', () => { it('should have proper heading hierarchy', () => { renderWithRouter('?token=test-token'); const heading = screen.getByRole('heading', { name: /verify your email/i }); expect(heading).toBeInTheDocument(); expect(heading.tagName).toBe('H1'); }); it('should have accessible buttons', () => { renderWithRouter('?token=test-token'); const button = screen.getByRole('button', { name: /confirm verification/i }); expect(button).toBeInTheDocument(); expect(button).toHaveClass('bg-brand-500'); }); it('should maintain focus on interactive elements', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=test-token'); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); const loginButton = screen.getByRole('button', { name: /go to login/i }); expect(loginButton).toBeInTheDocument(); }); it('should have visible text for all states', async () => { const mockPost = vi.mocked(apiClient.post); mockPost.mockResolvedValue({ data: { detail: 'Email verified successfully.' } }); renderWithRouter('?token=test-token'); // Pending state has text expect(screen.getByText('Verify Your Email')).toBeInTheDocument(); const verifyButton = screen.getByRole('button', { name: /confirm verification/i }); fireEvent.click(verifyButton); // Loading state has text await waitFor(() => { expect(screen.getByText('Verifying Your Email')).toBeInTheDocument(); }); // Success state has text await waitFor(() => { expect(screen.getByText('Email Verified!')).toBeInTheDocument(); }); }); }); });