Configure Root Layout with enhanced redirect logic
Implement comprehensive redirect logic in the Root Layout that: - Redirects unauthenticated users to login screen - Checks if authenticated users need to complete onboarding (firstName) - Prevents redirect loops by only redirecting on initial mount - Protects tabs from unauthorized access (redirects to login on logout) - Lets auth screens handle their own navigation via NavigationController Add comprehensive test suite with 8 tests covering: - Initial redirect scenarios (authenticated/unauthenticated) - Onboarding flow (missing firstName) - Logout protection - Loading states Update jest.config.js to support expo-status-bar, expo-splash-screen, react-native-reanimated, and react-native-worklets transforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e420631eba
commit
88bb6d7f8f
241
__tests__/app/_layout.test.tsx
Normal file
241
__tests__/app/_layout.test.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { render, waitFor } from '@testing-library/react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import { router, useRouter, useRootNavigationState, useSegments } from 'expo-router';
|
||||||
|
import RootLayout from '@/app/_layout';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useBLE } from '@/contexts/BLEContext';
|
||||||
|
|
||||||
|
// Mock react-native-reanimated
|
||||||
|
jest.mock('react-native-reanimated', () => ({
|
||||||
|
default: {},
|
||||||
|
useSharedValue: jest.fn,
|
||||||
|
useAnimatedStyle: jest.fn,
|
||||||
|
withTiming: jest.fn,
|
||||||
|
withSpring: jest.fn,
|
||||||
|
withDecay: jest.fn,
|
||||||
|
withDelay: jest.fn,
|
||||||
|
withRepeat: jest.fn,
|
||||||
|
withSequence: jest.fn,
|
||||||
|
cancelAnimation: jest.fn,
|
||||||
|
measure: jest.fn,
|
||||||
|
Easing: {},
|
||||||
|
Extrapolate: {},
|
||||||
|
runOnJS: jest.fn,
|
||||||
|
runOnUI: jest.fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('expo-router', () => {
|
||||||
|
const mockRouter = {
|
||||||
|
replace: jest.fn(),
|
||||||
|
push: jest.fn(),
|
||||||
|
};
|
||||||
|
const Stack = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||||
|
(Stack as any).Screen = () => null;
|
||||||
|
return {
|
||||||
|
Stack,
|
||||||
|
router: mockRouter,
|
||||||
|
useRouter: jest.fn(() => mockRouter),
|
||||||
|
useRootNavigationState: jest.fn(),
|
||||||
|
useSegments: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('expo-splash-screen', () => ({
|
||||||
|
preventAutoHideAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
hideAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/contexts/AuthContext', () => ({
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useAuth: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/contexts/BeneficiaryContext', () => ({
|
||||||
|
BeneficiaryProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/contexts/BLEContext', () => ({
|
||||||
|
BLEProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useBLE: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/hooks/use-color-scheme', () => ({
|
||||||
|
useColorScheme: jest.fn(() => 'light'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@stripe/stripe-react-native', () => ({
|
||||||
|
StripeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/components/ui/Toast', () => ({
|
||||||
|
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/components/ui/LoadingSpinner', () => {
|
||||||
|
const { Text } = require('react-native');
|
||||||
|
return {
|
||||||
|
LoadingSpinner: ({ message }: { message?: string; fullScreen?: boolean }) => (
|
||||||
|
<Text>{message || 'Loading'}</Text>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RootLayout Redirect Logic', () => {
|
||||||
|
const mockNavigationState = { key: 'root' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Reset splash screen mocks
|
||||||
|
(SplashScreen.hideAsync as jest.Mock).mockClear();
|
||||||
|
(SplashScreen.preventAutoHideAsync as jest.Mock).mockClear();
|
||||||
|
(useRootNavigationState as jest.Mock).mockReturnValue(mockNavigationState);
|
||||||
|
(useBLE as jest.Mock).mockReturnValue({
|
||||||
|
cleanupBLE: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial redirect - Not authenticated', () => {
|
||||||
|
it('should redirect to login when not authenticated and not in auth group', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(tabs)', 'dashboard']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).toHaveBeenCalledWith('/(auth)/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not redirect when not authenticated but already in auth group', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(auth)', 'login']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial redirect - Authenticated', () => {
|
||||||
|
it('should not redirect when authenticated and in auth group (login flow)', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitializing: false,
|
||||||
|
user: { user_id: 1, email: 'test@example.com', firstName: 'John' },
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(auth)', 'verify-otp']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to enter-name when authenticated user has no firstName', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitializing: false,
|
||||||
|
user: { user_id: 1, email: 'test@example.com', firstName: null },
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(tabs)', 'dashboard']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).toHaveBeenCalledWith('/(auth)/enter-name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not redirect when authenticated user has firstName and in tabs', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitializing: false,
|
||||||
|
user: { user_id: 1, email: 'test@example.com', firstName: 'John' },
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(tabs)', 'dashboard']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ongoing protection', () => {
|
||||||
|
it('should redirect to login when user logs out while in tabs', async () => {
|
||||||
|
const { rerender } = render(<RootLayout />);
|
||||||
|
|
||||||
|
// Initial state: authenticated
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitializing: false,
|
||||||
|
user: { user_id: 1, email: 'test@example.com', firstName: 'John' },
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(tabs)', 'dashboard']);
|
||||||
|
|
||||||
|
rerender(<RootLayout />);
|
||||||
|
|
||||||
|
// Clear previous calls
|
||||||
|
(router.replace as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
// Logout happens
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).toHaveBeenCalledWith('/(auth)/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading states', () => {
|
||||||
|
it('should show loading spinner while initializing', () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: true,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
(useSegments as jest.Mock).mockReturnValue([]);
|
||||||
|
|
||||||
|
const { getByText } = render(<RootLayout />);
|
||||||
|
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not redirect while navigation is not ready', async () => {
|
||||||
|
(useAuth as jest.Mock).mockReturnValue({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
(useRootNavigationState as jest.Mock).mockReturnValue(null);
|
||||||
|
(useSegments as jest.Mock).mockReturnValue(['(tabs)']);
|
||||||
|
|
||||||
|
render(<RootLayout />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@ -25,7 +25,7 @@ let splashHidden = false;
|
|||||||
|
|
||||||
function RootLayoutNav() {
|
function RootLayoutNav() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const { isAuthenticated, isInitializing } = useAuth();
|
const { isAuthenticated, isInitializing, user } = useAuth();
|
||||||
const { cleanupBLE } = useBLE();
|
const { cleanupBLE } = useBLE();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const navigationState = useRootNavigationState();
|
const navigationState = useRootNavigationState();
|
||||||
@ -68,23 +68,47 @@ function RootLayoutNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inAuthGroup = segments[0] === '(auth)';
|
const inAuthGroup = segments[0] === '(auth)';
|
||||||
|
const inTabsGroup = segments[0] === '(tabs)';
|
||||||
|
|
||||||
// INITIAL REDIRECT (only once after app starts):
|
// INITIAL REDIRECT (only once after app starts):
|
||||||
// - If not authenticated and not in auth → go to login
|
|
||||||
if (!hasInitialRedirect.current) {
|
if (!hasInitialRedirect.current) {
|
||||||
hasInitialRedirect.current = true;
|
hasInitialRedirect.current = true;
|
||||||
|
|
||||||
|
// Not authenticated → redirect to login
|
||||||
if (!isAuthenticated && !inAuthGroup) {
|
if (!isAuthenticated && !inAuthGroup) {
|
||||||
router.replace('/(auth)/login');
|
router.replace('/(auth)/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticated but in auth group → this means user just logged in
|
||||||
|
// Let the auth screens handle navigation via NavigationController
|
||||||
|
// They will call navigateAfterLogin() to determine the correct route
|
||||||
|
if (isAuthenticated && inAuthGroup) {
|
||||||
|
// Don't redirect - let auth screens finish their flow
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated and in tabs group → user opened app while logged in
|
||||||
|
// Check if user should be in onboarding flow
|
||||||
|
if (isAuthenticated && inTabsGroup && user) {
|
||||||
|
// If user has no name, redirect to complete profile
|
||||||
|
if (!user.firstName) {
|
||||||
|
router.replace('/(auth)/enter-name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, stay on current tab - user is properly authenticated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not authenticated, do NOTHING - let the auth screens handle their own navigation
|
// ONGOING PROTECTION:
|
||||||
|
// If user logs out, redirect to login
|
||||||
|
if (!isAuthenticated && inTabsGroup) {
|
||||||
|
router.replace('/(auth)/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
}, [isAuthenticated, isInitializing, navigationState?.key]);
|
}, [isAuthenticated, isInitializing, navigationState?.key, user]);
|
||||||
// IMPORTANT: isLoading NOT in deps - we don't want to react to loading state changes
|
// IMPORTANT: segments not in deps - we don't want to react to navigation changes
|
||||||
// segments also not in deps - we don't want to react to navigation changes
|
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return <LoadingSpinner fullScreen message="Loading..." />;
|
return <LoadingSpinner fullScreen message="Loading..." />;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ module.exports = {
|
|||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(expo|expo-router|expo-font|expo-asset|expo-constants|expo-modules-core|@expo/.*|@expo-google-fonts/.*|@react-native|react-native|@react-navigation|react-navigation|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-ble-plx|react-native-base64)/)',
|
'node_modules/(?!(expo|expo-router|expo-font|expo-asset|expo-constants|expo-modules-core|expo-status-bar|expo-splash-screen|expo-file-system|@expo/.*|@expo-google-fonts/.*|@react-native|react-native|react-native-reanimated|react-native-worklets|@react-navigation|react-navigation|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-ble-plx|react-native-base64|@stripe/.*)/)',
|
||||||
],
|
],
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user