diff --git a/src/__tests__/hooks/useAuth.test.tsx b/src/__tests__/hooks/useAuth.test.tsx new file mode 100644 index 0000000..9ee452b --- /dev/null +++ b/src/__tests__/hooks/useAuth.test.tsx @@ -0,0 +1,116 @@ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +import { AuthProvider, useAuth } from '../../hooks/useAuth'; +import mobileAuth from '../../services/mobileAuth'; +import { appLogger } from '../../utils/logger'; + +jest.mock('../../services/mobileAuth', () => ({ + __esModule: true, + default: { + login: jest.fn(), + loginWithBiometrics: jest.fn(), + logout: jest.fn(), + restoreSession: jest.fn(), + }, +})); + +jest.mock('../../utils/logger', () => ({ + __esModule: true, + appLogger: { + errorSync: jest.fn(), + warnSync: jest.fn(), + infoSync: jest.fn(), + debugSync: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + default: { + errorSync: jest.fn(), + }, +})); + +const mockMobileAuth = mobileAuth as jest.Mocked; +const mockAppLogger = appLogger as jest.Mocked; + +describe('useAuth login error handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMobileAuth.restoreSession.mockResolvedValue(null); + }); + + function renderAuthHook() { + return renderHook(() => useAuth(), { + wrapper: ({ children }) => {children}, + }); + } + + const createAxiosError = (code: string) => ({ + response: { + data: { + error: code, + }, + }, + }); + + it('maps invalid_grant to a friendly message and logs raw error', async () => { + mockMobileAuth.login.mockRejectedValueOnce(createAxiosError('invalid_grant')); + + const { result } = renderAuthHook(); + + await act(async () => { + await expect( + result.current.login({ email: 'user@example.com', password: 'password123' }) + ).rejects.toThrow('Your login session has expired. Please sign in again to continue.'); + }); + + expect(mockAppLogger.error).toHaveBeenCalledWith( + 'Auth login failed', + expect.objectContaining({ response: { data: { error: 'invalid_grant' } } }), + expect.any(Object) + ); + expect(result.current.isLoading).toBe(false); + }); + + it('maps access_denied to a friendly message and logs raw error', async () => { + mockMobileAuth.login.mockRejectedValueOnce(createAxiosError('access_denied')); + + const { result } = renderAuthHook(); + + await act(async () => { + await expect( + result.current.login({ email: 'user@example.com', password: 'password123' }) + ).rejects.toThrow('The email or password you entered is incorrect. Please try again.'); + }); + + expect(mockAppLogger.error).toHaveBeenCalledWith( + 'Auth login failed', + expect.objectContaining({ response: { data: { error: 'access_denied' } } }), + expect.any(Object) + ); + expect(result.current.isLoading).toBe(false); + }); + + it('falls back to generic message for unknown error codes and logs raw error', async () => { + mockMobileAuth.login.mockRejectedValueOnce(createAxiosError('unknown_code')); + + const { result } = renderAuthHook(); + + await act(async () => { + await expect( + result.current.login({ email: 'user@example.com', password: 'password123' }) + ).rejects.toThrow( + 'Unable to sign in right now. Please check your credentials and try again.' + ); + }); + + expect(mockAppLogger.error).toHaveBeenCalledWith( + 'Auth login failed', + expect.objectContaining({ response: { data: { error: 'unknown_code' } } }), + expect.any(Object) + ); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/src/__tests__/pages/mobile/MobileLogin.test.tsx b/src/__tests__/pages/mobile/MobileLogin.test.tsx new file mode 100644 index 0000000..eeba514 --- /dev/null +++ b/src/__tests__/pages/mobile/MobileLogin.test.tsx @@ -0,0 +1,200 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { MobileLogin } from '../../../pages/mobile/MobileLogin'; +import authService from '../../../services/mobileAuth'; +import { appLogger } from '../../../utils/logger'; + +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})); + +// Mock secureStorage - critical for useEffect +jest.mock('../../../services/secureStorage', () => ({ + isRememberMeEnabled: jest.fn().mockResolvedValue(false), +})); + +jest.mock('../../../components/mobile/BiometricPrompt', () => ({ + __esModule: true, + BiometricInlineButton: () => null, + BiometricPrompt: () => null, +})); + +// Mock lucide icons used in error banner and form +jest.mock('lucide-react-native', () => ({ + AlertCircle: 'AlertCircle', + Lock: 'Lock', + Mail: 'Mail', + Eye: 'Eye', + EyeOff: 'EyeOff', + LogIn: 'LogIn', + Chrome: 'Chrome', + Apple: 'Apple', + BookOpen: 'BookOpen', +})); + +jest.mock('../../../hooks', () => ({ + __esModule: true, + useBiometricAuth: () => ({ + isAvailable: false, + isEnabled: false, + biometricType: 'fingerprint', + authenticate: jest.fn().mockResolvedValue(null), + isLoading: false, + error: null, + clearError: jest.fn(), + }), + useDynamicFontSize: () => ({ scale: (value: number) => value }), + useFormValidation: () => ({ + errors: {}, + onChangeText: jest.fn(), + onBlur: jest.fn(), + validateAll: jest.fn(() => true), + }), +})); + +const originalConsoleError = console.error; +beforeAll(() => { + console.error = (...args: any[]) => { + originalConsoleError('CAPTURED ERROR:', ...args); + }; +}); +afterAll(() => { + console.error = originalConsoleError; +}); + +jest.mock('../../../services/mobileAuth', () => ({ + __esModule: true, + default: { + login: jest.fn(), + getRememberedEmail: jest.fn().mockResolvedValue(null), + }, +})); + +jest.mock('../../../utils/logger', () => ({ + __esModule: true, + appLogger: { + error: jest.fn(), + errorSync: jest.fn(), + warn: jest.fn(), + warnSync: jest.fn(), + info: jest.fn(), + infoSync: jest.fn(), + debug: jest.fn(), + debugSync: jest.fn(), + }, + default: { + error: jest.fn(), + errorSync: jest.fn(), + }, +})); + +const mockAuthService = authService as jest.Mocked; +const mockAppLogger = appLogger as jest.Mocked; + +describe('MobileLogin', () => { + const defaultProps = { + onLoginSuccess: jest.fn(), + onForgotPassword: jest.fn(), + onRegister: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const typeCredentials = (getByPlaceholderText: any, email: string, password: string) => { + fireEvent.changeText(getByPlaceholderText('you@example.com'), email); + fireEvent.changeText(getByPlaceholderText('Enter your password'), password); + }; + + const submitForm = (getByText: any) => { + fireEvent.press(getByText('Sign In')); + }; + + it('renders friendly invalid_grant message and logs raw error', async () => { + mockAuthService.login.mockRejectedValueOnce({ + response: { data: { error: 'invalid_grant' }, status: 400 }, + }); + + const { getByPlaceholderText, getByText, queryByText } = render( + + ); + + await act(async () => { + typeCredentials(getByPlaceholderText, 'user@example.com', 'password123'); + submitForm(getByText); + }); + + await waitFor(() => { + expect( + getByText('Your login session has expired. Please sign in again to continue.') + ).toBeTruthy(); + }); + + expect(queryByText('invalid_grant')).toBeNull(); + expect(mockAppLogger.error).toHaveBeenCalled(); + }); + + it('renders friendly access_denied message and logs raw error', async () => { + mockAuthService.login.mockRejectedValueOnce({ + response: { data: { error: 'access_denied' }, status: 400 }, + }); + + const { getByPlaceholderText, getByText, queryByText } = render( + + ); + + await act(async () => { + typeCredentials(getByPlaceholderText, 'user@example.com', 'password123'); + submitForm(getByText); + }); + + await waitFor(() => { + expect( + getByText('The email or password you entered is incorrect. Please try again.') + ).toBeTruthy(); + }); + + expect(queryByText('access_denied')).toBeNull(); + expect(mockAppLogger.error).toHaveBeenCalled(); + }); + + it('renders generic fallback for unknown error codes', async () => { + mockAuthService.login.mockRejectedValueOnce({ + response: { data: { error: 'unknown_code' }, status: 400 }, + }); + + const { getByPlaceholderText, getByText } = render(); + + await act(async () => { + typeCredentials(getByPlaceholderText, 'user@example.com', 'password123'); + submitForm(getByText); + }); + + await waitFor(() => { + expect( + getByText('Unable to sign in right now. Please check your credentials and try again.') + ).toBeTruthy(); + }); + expect(mockAppLogger.error).toHaveBeenCalled(); + }); + + it('renders generic fallback for network error without response', async () => { + mockAuthService.login.mockRejectedValueOnce(new Error('Network Error')); + + const { getByPlaceholderText, getByText } = render(); + + await act(async () => { + typeCredentials(getByPlaceholderText, 'user@example.com', 'password123'); + submitForm(getByText); + }); + + await waitFor(() => { + expect( + getByText('Unable to sign in right now. Please check your credentials and try again.') + ).toBeTruthy(); + }); + expect(mockAppLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 81e9a3a..03f9912 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -9,6 +9,7 @@ import React, { } from 'react'; import mobileAuth, { AuthUser } from '../services/mobileAuth'; +import { getAuthErrorMessage } from '../utils/authErrorMessages'; import { appLogger } from '../utils/logger'; interface AuthState { @@ -83,7 +84,16 @@ export const AuthProvider = ({ children }: AuthProviderProps): React.ReactElemen }); } catch (error) { setState(prev => ({ ...prev, isLoading: false })); - throw error; + + const authErrorCode = (error as { response?: { data?: { error?: string } } })?.response + ?.data?.error; + + await appLogger.error('Auth login failed', error, { + response: (error as { response?: { data?: unknown } })?.response?.data, + status: (error as { response?: { status?: number } })?.response?.status, + }); + + throw new Error(getAuthErrorMessage(authErrorCode)); } }, [] // stable: credentials come in as an argument, not a dep diff --git a/src/pages/mobile/MobileLogin.tsx b/src/pages/mobile/MobileLogin.tsx index ea8f9e4..c86393e 100644 --- a/src/pages/mobile/MobileLogin.tsx +++ b/src/pages/mobile/MobileLogin.tsx @@ -1,45 +1,40 @@ import { LinearGradient } from 'expo-linear-gradient'; import { - AlertCircle, - Apple, - BookOpen, - Chrome, - Eye, - EyeOff, - Lock, - LogIn, - Mail, + AlertCircle, + Apple, + BookOpen, + Chrome, + Eye, + EyeOff, + Lock, + LogIn, + Mail, } from 'lucide-react-native'; import React, { useEffect, useRef, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; import { - ActivityIndicator, - Alert, - Platform, - SafeAreaView, - ScrollView, - StyleSheet, - Switch, - Text, - TextInput, - TouchableOpacity, - View, + ActivityIndicator, + Alert, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, } from 'react-native'; - -import { BiometricInlineButton, BiometricPrompt } from '../../components/mobile/BiometricPrompt'; import { DelegatedKeyboardAvoidingView } from '../../components/common/DelegatedKeyboardAvoidingView'; +import { BiometricInlineButton, BiometricPrompt } from '../../components/mobile/BiometricPrompt'; import { MobileFormInput } from '../../components/mobile/MobileFormInput'; import { useBiometricAuth, useDynamicFontSize, useFormValidation } from '../../hooks'; import authService, { AuthResult } from '../../services/mobileAuth'; import * as secureStorage from '../../services/secureStorage'; +import { getAuthErrorMessage } from '../../utils/authErrorMessages'; +import { appLogger } from '../../utils/logger'; import { validateEmail, validateRequired } from '../../utils/validation'; -interface LoginFormValues { - email: string; - password: string; -} - // ─── Types ──────────────────────────────────────────────────────────────────── interface MobileLoginProps { @@ -62,19 +57,17 @@ export const MobileLogin: React.FC = ({ isDark = false, }) => { // ── Form ───────────────────────────────────────────────────────────────── - const { - control, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ defaultValues: { email: '', password: '' } }); - const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [serverError, setServerError] = useState(null); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordFocused, setPasswordFocused] = useState(false); const [showBiometricModal, setShowBiometricModal] = useState(false); + const [displayError, setDisplayError] = useState(null); + const emailRef = useRef(null); + const setError = (message: string | null) => setDisplayError(message); const passwordRef = useRef(null); const { @@ -107,11 +100,11 @@ export const MobileLogin: React.FC = ({ authService.getRememberedEmail(), secureStorage.isRememberMeEnabled(), ]); - if (savedEmail) setValue('email', savedEmail); + if (savedEmail) setEmail(savedEmail); if (savedRememberMe) setRememberMe(true); } loadRemembered(); - }, [setValue]); + }, []); // ── Auto-trigger biometric on mount if enabled ─────────────────────────── useEffect(() => { @@ -131,19 +124,30 @@ export const MobileLogin: React.FC = ({ // ── Password login ─────────────────────────────────────────────────────── const handlePasswordLogin = async () => { - if (!validateAll({ email, password })) return; + const formValues = { email, password }; + + if (!validateAll(formValues)) return; setError(null); setIsLoading(true); try { const result = await authService.login({ - email: data.email.trim().toLowerCase(), - password: data.password, + email: formValues.email.trim().toLowerCase(), + password: formValues.password, rememberMe, }); onLoginSuccess(result); } catch (err) { - setServerError(err instanceof Error ? err.message : 'Login failed. Please try again.'); + const authErrorCode = (err as { response?: { data?: { error?: string } } })?.response?.data + ?.error; + + await appLogger.error('MobileLogin login failed', err, { + response: (err as { response?: { data?: unknown } })?.response?.data, + status: (err as { response?: { status?: number } })?.response?.status, + }); + + const friendlyMessage = getAuthErrorMessage(authErrorCode); + setError(friendlyMessage); } finally { setIsLoading(false); } @@ -332,7 +336,7 @@ export const MobileLogin: React.FC = ({ {/* Primary CTA */} diff --git a/src/utils/authErrorMessages.ts b/src/utils/authErrorMessages.ts new file mode 100644 index 0000000..81a7154 --- /dev/null +++ b/src/utils/authErrorMessages.ts @@ -0,0 +1,17 @@ +export const authErrorMessages: Record = { + invalid_grant: 'Your login session has expired. Please sign in again to continue.', + access_denied: 'The email or password you entered is incorrect. Please try again.', + invalid_client: 'Authentication service error. Please try again later.', + invalid_request: 'Invalid request. Please check your input.', + unsupported_grant_type: 'Authentication method not supported.', + server_error: 'Server error. Please try again later.', + // Add more as needed from backend +}; + +const GENERIC_AUTH_ERROR_MESSAGE = + 'Unable to sign in right now. Please check your credentials and try again.'; + +export const getAuthErrorMessage = (errorCode: string | undefined): string => { + if (!errorCode) return GENERIC_AUTH_ERROR_MESSAGE; + return authErrorMessages[errorCode] || GENERIC_AUTH_ERROR_MESSAGE; +};