import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import VerifyOtpPage from '../app/(auth)/verify-otp/page'; import api from '../lib/api'; // Helper to render with Suspense function renderWithSuspense(component: React.ReactNode) { return render( Loading...}> {component} ); } // Mock Next.js router jest.mock('next/navigation', () => ({ useRouter: jest.fn(), useSearchParams: jest.fn(), })); // Mock API jest.mock('../lib/api', () => ({ __esModule: true, default: { verifyOTP: jest.fn(), requestOTP: jest.fn(), getProfile: jest.fn(), getAllBeneficiaries: jest.fn(), }, })); describe('VerifyOtpPage', () => { const mockPush = jest.fn(); const mockReplace = jest.fn(); const mockBack = jest.fn(); const mockGet = jest.fn(); beforeEach(() => { jest.clearAllMocks(); (useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace, back: mockBack, }); (useSearchParams as jest.Mock).mockReturnValue({ get: mockGet, }); mockGet.mockImplementation((key: string) => { if (key === 'email') return 'test@example.com'; return null; }); }); it('renders OTP verification form correctly', () => { render(); expect(screen.getByText('Check your email')).toBeInTheDocument(); expect(screen.getByText('test@example.com')).toBeInTheDocument(); expect(screen.getByText('Enter verification code')).toBeInTheDocument(); }); it('displays all 6 OTP input fields', () => { render(); const inputs = screen.getAllByRole('textbox'); expect(inputs).toHaveLength(6); inputs.forEach(input => { expect(input).toHaveAttribute('maxLength', '1'); expect(input).toHaveAttribute('inputMode', 'numeric'); }); }); it('auto-focuses first input on mount', () => { render(); const inputs = screen.getAllByRole('textbox'); expect(document.activeElement).toBe(inputs[0]); }); it('only allows numeric input', () => { render(); const input = screen.getAllByRole('textbox')[0]; fireEvent.change(input, { target: { value: 'a' } }); expect(input).toHaveValue(''); }); it('accepts numeric input and moves to next field', () => { render(); const inputs = screen.getAllByRole('textbox'); fireEvent.change(inputs[0], { target: { value: '1' } }); expect(inputs[0]).toHaveValue('1'); expect(document.activeElement).toBe(inputs[1]); }); it('handles backspace navigation', () => { render(); const inputs = screen.getAllByRole('textbox'); // Enter digits fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[1], { target: { value: '2' } }); // Clear the second field first fireEvent.change(inputs[1], { target: { value: '' } }); // Press backspace on empty field fireEvent.keyDown(inputs[1], { key: 'Backspace' }); expect(document.activeElement).toBe(inputs[0]); }); it('handles paste of 6-digit code', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'mock-token', user: { id: '1', email: 'test@example.com', first_name: 'Test', last_name: 'User' }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 1, firstName: 'Test', lastName: 'User' }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [{ id: 1, name: 'Beneficiary' }], }); render(); const inputs = screen.getAllByRole('textbox'); // Simulate paste event with clipboardData fireEvent.paste(inputs[0], { clipboardData: { getData: () => '123456', }, }); await waitFor(() => { expect(api.verifyOTP).toHaveBeenCalledWith('test@example.com', '123456'); }); }); it('filters non-numeric characters from pasted data', () => { render(); const inputs = screen.getAllByRole('textbox'); // Simulate paste event with clipboardData fireEvent.paste(inputs[0], { clipboardData: { getData: () => 'a1b2c3d4e5f6', }, }); expect(inputs[0]).toHaveValue('1'); expect(inputs[1]).toHaveValue('2'); expect(inputs[2]).toHaveValue('3'); expect(inputs[3]).toHaveValue('4'); expect(inputs[4]).toHaveValue('5'); expect(inputs[5]).toHaveValue('6'); }); it('auto-submits when all 6 digits are entered', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'mock-token', user: { id: '1', email: 'test@example.com', first_name: 'Test' }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 1, firstName: 'Test' }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [{ id: 1 }], }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(api.verifyOTP).toHaveBeenCalledWith('test@example.com', '123456'); }); }); it('shows error for incomplete code', async () => { render(); const inputs = screen.getAllByRole('textbox'); // Enter only 3 digits fireEvent.change(inputs[0], { target: { value: '1' } }); fireEvent.change(inputs[1], { target: { value: '2' } }); fireEvent.change(inputs[2], { target: { value: '3' } }); // Button should be disabled with incomplete code, so we can't test the error this way // Instead, let's verify that button is disabled const verifyButton = screen.getByRole('button', { name: /verify code/i }); expect(verifyButton).toBeDisabled(); }); it('navigates to enter-name if user has no firstName', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'mock-token', user: { id: '1', email: 'test@example.com', first_name: null }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 1, firstName: null }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [], }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/enter-name'); }); }); it('navigates to add-loved-one if user has no beneficiaries', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'mock-token', user: { id: '1', email: 'test@example.com', first_name: 'Test' }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 1, firstName: 'Test' }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [], }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/add-loved-one'); }); }); it('navigates to dashboard if user has profile and beneficiaries', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'mock-token', user: { id: '1', email: 'test@example.com', first_name: 'Test', last_name: 'User' }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 1, firstName: 'Test', lastName: 'User' }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [{ id: 1, name: 'Beneficiary' }], }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith('/dashboard'); }); }); it('shows error message for invalid OTP', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: false, error: { message: 'Invalid verification code' }, }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(screen.getByText('Invalid verification code')).toBeInTheDocument(); }); }); it('clears input fields after failed verification', async () => { (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: false, error: { message: 'Invalid code' }, }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { inputs.forEach(input => { expect(input).toHaveValue(''); }); }); }); it('shows loading state during verification', async () => { (api.verifyOTP as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(screen.getByText('Verifying...')).toBeInTheDocument(); }); }); it('disables inputs during verification', async () => { (api.verifyOTP as jest.Mock).mockImplementation(() => new Promise(() => {})); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { inputs.forEach(input => { expect(input).toBeDisabled(); }); }); }); it('shows resend countdown initially', () => { render(); expect(screen.getByText(/Resend code in \d+s/)).toBeInTheDocument(); }); it('enables resend button after countdown', async () => { jest.useFakeTimers(); render(); // Fast-forward 60 seconds with act wrapper for (let i = 0; i < 60; i++) { act(() => { jest.advanceTimersByTime(1000); }); } await waitFor(() => { expect(screen.getByText('Resend code')).toBeInTheDocument(); }); jest.useRealTimers(); }); it('resends OTP code successfully', async () => { jest.useFakeTimers(); (api.requestOTP as jest.Mock).mockResolvedValue({ ok: true }); render(); // Fast-forward to enable resend for (let i = 0; i < 60; i++) { act(() => { jest.advanceTimersByTime(1000); }); } await waitFor(() => { const resendButton = screen.getByText('Resend code'); fireEvent.click(resendButton); }); await waitFor(() => { expect(api.requestOTP).toHaveBeenCalledWith('test@example.com'); }); jest.useRealTimers(); }); it('shows error on failed resend', async () => { jest.useFakeTimers(); (api.requestOTP as jest.Mock).mockResolvedValue({ ok: false, error: { message: 'Failed to send OTP' }, }); render(); // Fast-forward to enable resend for (let i = 0; i < 60; i++) { act(() => { jest.advanceTimersByTime(1000); }); } await waitFor(() => { const resendButton = screen.getByText('Resend code'); fireEvent.click(resendButton); }); await waitFor(() => { expect(screen.getByText('Failed to resend code. Please try again.')).toBeInTheDocument(); }); jest.useRealTimers(); }); it('navigates back to login on back button click', () => { render(); const backButton = screen.getByText('Back to login').closest('button'); fireEvent.click(backButton!); expect(mockBack).toHaveBeenCalled(); }); it('handles network errors gracefully', async () => { (api.verifyOTP as jest.Mock).mockRejectedValue(new Error('Network error')); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(screen.getByText('Network error. Please try again.')).toBeInTheDocument(); }); }); it('saves auth token to localStorage on successful verification', async () => { const mockSetItem = jest.spyOn(Storage.prototype, 'setItem'); (api.verifyOTP as jest.Mock).mockResolvedValue({ ok: true, data: { token: 'test-token', user: { id: '123', email: 'test@example.com', first_name: 'Test' }, }, }); (api.getProfile as jest.Mock).mockResolvedValue({ ok: true, data: { user: { id: 123, firstName: 'Test' }, }, }); (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ ok: true, data: [{ id: 1 }], }); render(); const inputs = screen.getAllByRole('textbox'); '123456'.split('').forEach((digit, index) => { fireEvent.change(inputs[index], { target: { value: digit } }); }); await waitFor(() => { expect(mockSetItem).toHaveBeenCalledWith('accessToken', 'test-token'); expect(mockSetItem).toHaveBeenCalledWith('userId', expect.any(String)); }); mockSetItem.mockRestore(); }); });