Files
smoothschedule/frontend/src/pages/__tests__/VerifyEmail.test.tsx
poduck 8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00

757 lines
25 KiB
TypeScript

/**
* 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 = () => (
<MemoryRouter initialEntries={[`/verify-email${searchParams}`]}>
<Routes>
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/login" element={<div>Login Page</div>} />
</Routes>
</MemoryRouter>
);
return render(<TestComponent />);
};
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();
});
});
});
});