import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ checkLoginOtp: vi.fn(), replace: vi.fn(), sendLoginOtp: vi.fn(), })) vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mocks.replace }), useSearchParams: () => new URLSearchParams('redirect=%2Fbookings%2Fbooking-1'), })) vi.mock('@/hooks/useAuth', () => ({ default: () => ({ checkLoginOtp: mocks.checkLoginOtp, completeProfile: vi.fn(), sendLoginOtp: mocks.sendLoginOtp, user: null, }), })) vi.mock('@/lib/toast', () => ({ addToast: vi.fn() })) vi.mock('@/lib/analytics', () => ({ ANALYTICS_EVENTS: { AUTH_FAILED: 'auth_failed', AUTH_OPENED: 'auth_opened', LOGIN: 'login', OTP_REQUESTED: 'otp_requested', }, trackAnalyticsEvent: vi.fn(), })) vi.mock('@/hooks/useSmsOtpAutofill', () => ({ useSmsOtpAutofill: () => undefined })) import { useAuthFlow } from '@/features/auth/useAuthFlow' describe('useAuthFlow functional OTP journey', () => { beforeEach(() => { mocks.sendLoginOtp.mockResolvedValue({ purpose: 'login', expiresIn: 120, resendIn: 60 }) mocks.checkLoginOtp.mockResolvedValue('active') }) afterEach(() => { vi.clearAllMocks() }) it('normalizes the entered mobile, verifies the OTP, then redirects to the requested consumer destination', async () => { const { result } = renderHook(() => useAuthFlow()) await act(() => result.current.sendOtp({ mobile: '۰۹۱۲۳۴۵۶۷۸۹' })) expect(mocks.sendLoginOtp).toHaveBeenCalledWith({ mobile: '09123456789' }) expect(result.current.step).toBe('otp') await act(() => result.current.checkOtp({ code: '۱۲۳۴' })) expect(mocks.checkLoginOtp).toHaveBeenCalledWith({ mobile: '09123456789', code: '1234' }) expect(mocks.replace).toHaveBeenCalledWith('/bookings/booking-1') }) it('keeps the user on the OTP step and does not redirect when verification fails', async () => { const invalidOtp = Object.assign(new Error('invalid otp'), { apiCode: 'OTP_INVALID' }) mocks.checkLoginOtp.mockRejectedValue(invalidOtp) const { result } = renderHook(() => useAuthFlow()) await act(() => result.current.sendOtp({ mobile: '09123456789' })) await act(() => result.current.checkOtp({ code: '1234' })) expect(result.current.step).toBe('otp') expect(result.current.otpError).not.toBeNull() expect(mocks.replace).not.toHaveBeenCalled() }) it('moves a pending user to profile completion instead of navigating away', async () => { mocks.checkLoginOtp.mockResolvedValue('pending') const { result } = renderHook(() => useAuthFlow()) await act(() => result.current.sendOtp({ mobile: '09123456789' })) await act(() => result.current.checkOtp({ code: '1234' })) expect(result.current.step).toBe('profile') expect(mocks.replace).not.toHaveBeenCalled() }) it('auto-submits when the OTP field reaches the required length', async () => { const { result } = renderHook(() => useAuthFlow()) await act(() => result.current.sendOtp({ mobile: '09123456789' })) act(() => { result.current.checkOtpForm.setValue('code', '1234', { shouldDirty: true, shouldValidate: true }) }) await act(async () => { await Promise.resolve() }) expect(mocks.checkLoginOtp).toHaveBeenCalledWith({ mobile: '09123456789', code: '1234' }) expect(mocks.replace).toHaveBeenCalledWith('/bookings/booking-1') }) it('does not re-submit the same completed OTP after a failed verification', async () => { const invalidOtp = Object.assign(new Error('invalid otp'), { apiCode: 'OTP_INVALID' }) mocks.checkLoginOtp.mockRejectedValue(invalidOtp) const { result } = renderHook(() => useAuthFlow()) await act(() => result.current.sendOtp({ mobile: '09123456789' })) act(() => { result.current.checkOtpForm.setValue('code', '1234', { shouldDirty: true, shouldValidate: true }) }) await act(async () => { await Promise.resolve() }) expect(mocks.checkLoginOtp).toHaveBeenCalledTimes(1) await act(async () => { await Promise.resolve() }) expect(mocks.checkLoginOtp).toHaveBeenCalledTimes(1) }) })