Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/__tests__/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof mobileAuth>;
const mockAppLogger = appLogger as jest.Mocked<typeof appLogger>;

describe('useAuth login error handling', () => {
beforeEach(() => {
jest.clearAllMocks();
mockMobileAuth.restoreSession.mockResolvedValue(null);
});

function renderAuthHook() {
return renderHook(() => useAuth(), {
wrapper: ({ children }) => <AuthProvider>{children}</AuthProvider>,
});
}

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);
});
});
200 changes: 200 additions & 0 deletions src/__tests__/pages/mobile/MobileLogin.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof authService>;
const mockAppLogger = appLogger as jest.Mocked<typeof appLogger>;

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(
<MobileLogin {...defaultProps} />
);

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(
<MobileLogin {...defaultProps} />
);

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(<MobileLogin {...defaultProps} />);

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(<MobileLogin {...defaultProps} />);

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();
});
});
12 changes: 11 additions & 1 deletion src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading