diff --git a/__tests__/app/_layout.test.tsx b/__tests__/app/_layout.test.tsx
new file mode 100644
index 0000000..9c8c85e
--- /dev/null
+++ b/__tests__/app/_layout.test.tsx
@@ -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 }) => (
+ {message || 'Loading'}
+ ),
+ };
+});
+
+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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // Clear previous calls
+ (router.replace as jest.Mock).mockClear();
+
+ // Logout happens
+ (useAuth as jest.Mock).mockReturnValue({
+ isAuthenticated: false,
+ isInitializing: false,
+ user: null,
+ });
+
+ rerender();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(router.replace).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 2e2b902..56f9422 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -25,7 +25,7 @@ let splashHidden = false;
function RootLayoutNav() {
const colorScheme = useColorScheme();
- const { isAuthenticated, isInitializing } = useAuth();
+ const { isAuthenticated, isInitializing, user } = useAuth();
const { cleanupBLE } = useBLE();
const segments = useSegments();
const navigationState = useRootNavigationState();
@@ -68,23 +68,47 @@ function RootLayoutNav() {
}
const inAuthGroup = segments[0] === '(auth)';
+ const inTabsGroup = segments[0] === '(tabs)';
// INITIAL REDIRECT (only once after app starts):
- // - If not authenticated and not in auth → go to login
if (!hasInitialRedirect.current) {
hasInitialRedirect.current = true;
+ // Not authenticated → redirect to login
if (!isAuthenticated && !inAuthGroup) {
router.replace('/(auth)/login');
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]);
- // IMPORTANT: isLoading NOT in deps - we don't want to react to loading state changes
- // segments also not in deps - we don't want to react to navigation changes
+ }, [isAuthenticated, isInitializing, navigationState?.key, user]);
+ // IMPORTANT: segments not in deps - we don't want to react to navigation changes
if (isInitializing) {
return ;
diff --git a/jest.config.js b/jest.config.js
index 389e21d..aa578de 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,7 +3,7 @@ module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['/jest.setup.js'],
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'],
moduleNameMapper: {