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) {