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: {