From 7a9d85c0d05c4d5fb48bd681c9a025f1420914e5 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 17:44:42 -0800 Subject: [PATCH] Add comprehensive tests for OTP verification page and fix API integration bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create 24 comprehensive test cases for verify-otp page covering: * Input validation and auto-focus * Keyboard navigation and backspace handling * Paste functionality with filtering * Auto-submit when complete * Navigation flows (enter-name, add-loved-one, dashboard) * Error handling and loading states * Resend functionality with countdown * localStorage integration - Fix critical API integration bugs in verify-otp page: * Correct API response property names (token/user.id instead of accessToken/userId) * Replace non-existent getMe() with getProfile() and getAllBeneficiaries() * Fix user data extraction from nested response structure - All 24 tests passing - Linting warnings consistent with existing codebase 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- web/__tests__/verify-otp.test.tsx | 509 +++++++++++++++++++++++++++++ web/app/(auth)/verify-otp/page.tsx | 16 +- 2 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 web/__tests__/verify-otp.test.tsx diff --git a/web/__tests__/verify-otp.test.tsx b/web/__tests__/verify-otp.test.tsx new file mode 100644 index 0000000..cf1d832 --- /dev/null +++ b/web/__tests__/verify-otp.test.tsx @@ -0,0 +1,509 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import VerifyOtpPage from '../app/(auth)/verify-otp/page'; +import api from '../lib/api'; + +// 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(); + }); +}); diff --git a/web/app/(auth)/verify-otp/page.tsx b/web/app/(auth)/verify-otp/page.tsx index 104a6e6..a6a0352 100644 --- a/web/app/(auth)/verify-otp/page.tsx +++ b/web/app/(auth)/verify-otp/page.tsx @@ -84,16 +84,20 @@ export default function VerifyOtpPage() { const result = await api.verifyOTP(email, otpCode); if (result.ok && result.data) { - // Save auth token - localStorage.setItem('accessToken', result.data.accessToken); - localStorage.setItem('userId', result.data.userId.toString()); + // Save auth token (already saved by API, but double-check) + localStorage.setItem('accessToken', result.data.token); + localStorage.setItem('userId', result.data.user.id.toString()); // Get user profile to determine next screen - const profileResult = await api.getMe(); + const profileResult = await api.getProfile(); if (profileResult.ok && profileResult.data) { - const user = profileResult.data.user; - const beneficiaries = profileResult.data.beneficiaries || []; + // Get user data (getProfile returns nested 'user' object) + const user = profileResult.data.user || profileResult.data; + + // Get beneficiaries + const beneficiariesResult = await api.getAllBeneficiaries(); + const beneficiaries = beneficiariesResult.ok && beneficiariesResult.data ? beneficiariesResult.data : []; // Navigation logic (similar to NavigationController) if (!user.firstName) {