Add comprehensive null safety to navigation system

Implemented null/undefined handling throughout NavigationController
and useNavigationFlow hook to prevent crashes from invalid data:

- Added null checks for all profile and beneficiary parameters
- Validated beneficiary IDs before navigation (type and value checks)
- Added fallback routes when data is invalid or missing
- Implemented safe navigation with error handling and logging
- Added defensive guards for optional purchaseResult parameter

Key improvements:
- getRouteAfterLogin: handles null profile, null beneficiaries, invalid IDs
- getRouteForBeneficiarySetup: validates beneficiary exists before routing
- getRouteAfterAddBeneficiary: validates beneficiary ID type and value
- getRouteAfterPurchase: handles null purchaseResult safely
- getBeneficiaryRoute: returns fallback route for invalid beneficiaries
- navigate hook: wraps router calls in try-catch with validation

All methods now gracefully handle edge cases without crashing,
logging warnings for debugging while maintaining UX flow.

Tests included for all null/undefined scenarios.
This commit is contained in:
Sergei 2026-01-29 12:05:29 -08:00
parent 7d9e7e37bf
commit deddd3d5bc
4 changed files with 951 additions and 28 deletions

View File

@ -0,0 +1,424 @@
/**
* useNavigationFlow Hook - Null Safety Tests
*
* Tests for null/undefined handling in navigation hook
*/
import { renderHook, act } from '@testing-library/react-native';
import { useNavigationFlow } from '../useNavigationFlow';
import { ROUTES } from '@/services/NavigationController';
import type { Beneficiary } from '@/types';
// Mock expo-router
const mockPush = jest.fn();
const mockReplace = jest.fn();
jest.mock('expo-router', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}));
describe('useNavigationFlow - Null Safety', () => {
beforeEach(() => {
jest.clearAllMocks();
// Suppress console warnings during tests
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('navigate', () => {
it('should handle null navigation result', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigate(null as any);
});
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid navigation result:',
null
);
});
it('should handle undefined navigation result', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigate(undefined as any);
});
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid navigation result:',
undefined
);
});
it('should handle navigation result without path', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigate({ path: '' } as any);
});
expect(mockPush).not.toHaveBeenCalled();
expect(mockReplace).not.toHaveBeenCalled();
});
it('should navigate successfully with valid result', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigate({ path: ROUTES.TABS.DASHBOARD });
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.TABS.DASHBOARD);
});
it('should use replace when specified', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigate({ path: ROUTES.TABS.DASHBOARD }, true);
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.TABS.DASHBOARD);
expect(mockPush).not.toHaveBeenCalled();
});
});
describe('navigateAfterLogin', () => {
it('should handle null profile', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterLogin(null, []);
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.AUTH.ENTER_NAME);
});
it('should handle undefined profile', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterLogin(undefined, []);
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.AUTH.ENTER_NAME);
});
it('should handle null beneficiaries', () => {
const { result } = renderHook(() => useNavigationFlow());
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
act(() => {
result.current.navigateAfterLogin(profile, null);
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaries', () => {
const { result } = renderHook(() => useNavigationFlow());
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
act(() => {
result.current.navigateAfterLogin(profile, undefined);
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
});
describe('navigateAfterAddBeneficiary', () => {
it('should handle null beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterAddBeneficiary(null, false);
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterAddBeneficiary(undefined, false);
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should navigate successfully with valid beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterAddBeneficiary(1, false);
});
expect(mockPush).toHaveBeenCalledWith({
pathname: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId: 1 },
});
});
});
describe('navigateAfterPurchase', () => {
it('should handle null beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterPurchase(null, {});
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterPurchase(undefined, {});
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle null options', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterPurchase(1, null);
});
expect(mockPush).toHaveBeenCalled();
});
it('should handle undefined options', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateAfterPurchase(1, undefined);
});
expect(mockPush).toHaveBeenCalled();
});
});
describe('navigateToBeneficiary', () => {
it('should handle null beneficiary', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateToBeneficiary(null, 'view');
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.TABS.BENEFICIARIES);
});
it('should handle undefined beneficiary', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.navigateToBeneficiary(undefined, 'view');
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.TABS.BENEFICIARIES);
});
it('should navigate successfully with valid beneficiary', () => {
const { result } = renderHook(() => useNavigationFlow());
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
};
act(() => {
result.current.navigateToBeneficiary(beneficiary, 'view');
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.BENEFICIARY.DETAIL(1));
});
});
describe('goToPurchase', () => {
it('should handle null beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToPurchase(null);
});
expect(mockPush).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid beneficiaryId for purchase:',
null
);
});
it('should handle undefined beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToPurchase(undefined);
});
expect(mockPush).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid beneficiaryId for purchase:',
undefined
);
});
it('should handle invalid beneficiaryId type', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToPurchase('invalid' as any);
});
expect(mockPush).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid beneficiaryId for purchase:',
'invalid'
);
});
it('should navigate successfully with valid beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToPurchase(1);
});
expect(mockPush).toHaveBeenCalledWith({
pathname: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId: 1 },
});
});
});
describe('goToActivate', () => {
it('should handle null beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToActivate(null);
});
expect(mockPush).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid beneficiaryId for activation:',
null
);
});
it('should handle undefined beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToActivate(undefined);
});
expect(mockPush).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(
'[useNavigationFlow] Invalid beneficiaryId for activation:',
undefined
);
});
it('should navigate successfully with valid beneficiaryId', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToActivate(1);
});
expect(mockPush).toHaveBeenCalledWith({
pathname: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId: 1 },
});
});
it('should include demo param when provided', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToActivate(1, true);
});
expect(mockPush).toHaveBeenCalledWith({
pathname: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId: 1, demo: true },
});
});
});
describe('navigation shortcuts', () => {
it('should navigate to dashboard', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToDashboard();
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.TABS.DASHBOARD);
});
it('should navigate to login', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToLogin();
});
expect(mockReplace).toHaveBeenCalledWith(ROUTES.AUTH.LOGIN);
});
it('should navigate to add beneficiary', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToAddBeneficiary();
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should navigate to profile', () => {
const { result } = renderHook(() => useNavigationFlow());
act(() => {
result.current.goToProfile();
});
expect(mockPush).toHaveBeenCalledWith(ROUTES.TABS.PROFILE);
});
});
});

View File

@ -21,15 +21,25 @@ export function useNavigationFlow() {
/** /**
* Execute navigation based on NavigationResult * Execute navigation based on NavigationResult
*/ */
const navigate = useCallback((result: NavigationResult, replace = false) => { const navigate = useCallback((result: NavigationResult | null | undefined, replace = false) => {
// Null safety: validate navigation result
if (!result || !result.path) {
console.warn('[useNavigationFlow] Invalid navigation result:', result);
return;
}
const href = result.params const href = result.params
? { pathname: result.path, params: result.params } ? { pathname: result.path, params: result.params }
: result.path; : result.path;
if (replace) { try {
router.replace(href as any); if (replace) {
} else { router.replace(href as any);
router.push(href as any); } else {
router.push(href as any);
}
} catch (error) {
console.error('[useNavigationFlow] Navigation error:', error);
} }
}, [router]); }, [router]);
@ -37,8 +47,8 @@ export function useNavigationFlow() {
* Navigate after successful login/OTP verification * Navigate after successful login/OTP verification
*/ */
const navigateAfterLogin = useCallback(( const navigateAfterLogin = useCallback((
profile: UserProfile | null, profile: UserProfile | null | undefined,
beneficiaries: Beneficiary[] beneficiaries: Beneficiary[] | null | undefined
) => { ) => {
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries); const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
navigate(result, true); // replace to prevent going back to login navigate(result, true); // replace to prevent going back to login
@ -48,7 +58,7 @@ export function useNavigationFlow() {
* Navigate after creating a new beneficiary * Navigate after creating a new beneficiary
*/ */
const navigateAfterAddBeneficiary = useCallback(( const navigateAfterAddBeneficiary = useCallback((
beneficiaryId: number, beneficiaryId: number | null | undefined,
hasExistingDevices: boolean hasExistingDevices: boolean
) => { ) => {
const result = NavigationController.getRouteAfterAddBeneficiary( const result = NavigationController.getRouteAfterAddBeneficiary(
@ -62,8 +72,8 @@ export function useNavigationFlow() {
* Navigate after purchase completion * Navigate after purchase completion
*/ */
const navigateAfterPurchase = useCallback(( const navigateAfterPurchase = useCallback((
beneficiaryId: number, beneficiaryId: number | null | undefined,
options: { skipToActivate?: boolean; demo?: boolean } = {} options: { skipToActivate?: boolean; demo?: boolean } | null | undefined = {}
) => { ) => {
const result = NavigationController.getRouteAfterPurchase(beneficiaryId, options); const result = NavigationController.getRouteAfterPurchase(beneficiaryId, options);
navigate(result); navigate(result);
@ -72,7 +82,7 @@ export function useNavigationFlow() {
/** /**
* Navigate after device activation * Navigate after device activation
*/ */
const navigateAfterActivation = useCallback((beneficiaryId: number) => { const navigateAfterActivation = useCallback((beneficiaryId: number | null | undefined) => {
const result = NavigationController.getRouteAfterActivation(beneficiaryId); const result = NavigationController.getRouteAfterActivation(beneficiaryId);
navigate(result, true); // replace to prevent going back navigate(result, true); // replace to prevent going back
}, [navigate]); }, [navigate]);
@ -81,7 +91,7 @@ export function useNavigationFlow() {
* Navigate to beneficiary-specific screens * Navigate to beneficiary-specific screens
*/ */
const navigateToBeneficiary = useCallback(( const navigateToBeneficiary = useCallback((
beneficiary: Beneficiary, beneficiary: Beneficiary | null | undefined,
action: 'view' | 'subscription' | 'equipment' | 'share' = 'view' action: 'view' | 'subscription' | 'equipment' | 'share' = 'view'
) => { ) => {
const result = NavigationController.getBeneficiaryRoute(beneficiary, action); const result = NavigationController.getBeneficiaryRoute(beneficiary, action);
@ -91,7 +101,7 @@ export function useNavigationFlow() {
/** /**
* Navigate to beneficiary setup flow (purchase or activate) * Navigate to beneficiary setup flow (purchase or activate)
*/ */
const navigateToBeneficiarySetup = useCallback((beneficiary: Beneficiary) => { const navigateToBeneficiarySetup = useCallback((beneficiary: Beneficiary | null | undefined) => {
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary); const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
navigate(result); navigate(result);
}, [navigate]); }, [navigate]);
@ -122,14 +132,26 @@ export function useNavigationFlow() {
navigate({ path: ROUTES.AUTH.ADD_LOVED_ONE }); navigate({ path: ROUTES.AUTH.ADD_LOVED_ONE });
}, [navigate]); }, [navigate]);
const goToPurchase = useCallback((beneficiaryId: number) => { const goToPurchase = useCallback((beneficiaryId: number | null | undefined) => {
// Null safety: only navigate if beneficiaryId is valid
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
console.warn('[useNavigationFlow] Invalid beneficiaryId for purchase:', beneficiaryId);
return;
}
navigate({ navigate({
path: ROUTES.AUTH.PURCHASE, path: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId }, params: { beneficiaryId },
}); });
}, [navigate]); }, [navigate]);
const goToActivate = useCallback((beneficiaryId: number, demo?: boolean) => { const goToActivate = useCallback((beneficiaryId: number | null | undefined, demo?: boolean) => {
// Null safety: only navigate if beneficiaryId is valid
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
console.warn('[useNavigationFlow] Invalid beneficiaryId for activation:', beneficiaryId);
return;
}
navigate({ navigate({
path: ROUTES.AUTH.ACTIVATE, path: ROUTES.AUTH.ACTIVATE,
params: demo !== undefined ? { beneficiaryId, demo } : { beneficiaryId }, params: demo !== undefined ? { beneficiaryId, demo } : { beneficiaryId },

View File

@ -86,8 +86,8 @@ export const NavigationController = {
* 4. User has active beneficiary dashboard * 4. User has active beneficiary dashboard
*/ */
getRouteAfterLogin( getRouteAfterLogin(
profile: UserProfile | null, profile: UserProfile | null | undefined,
beneficiaries: Beneficiary[] beneficiaries: Beneficiary[] | null | undefined
): NavigationResult { ): NavigationResult {
// Step 1: Check if user has name // Step 1: Check if user has name
if (!profile?.firstName) { if (!profile?.firstName) {
@ -112,6 +112,14 @@ export const NavigationController = {
// Step 4: Single beneficiary - check if needs setup // Step 4: Single beneficiary - check if needs setup
const singleBeneficiary = beneficiaries[0]; const singleBeneficiary = beneficiaries[0];
// Null safety: verify beneficiary exists and has valid id
if (!singleBeneficiary || typeof singleBeneficiary.id !== 'number') {
return {
path: ROUTES.AUTH.ADD_LOVED_ONE,
};
}
const isActive = singleBeneficiary.hasDevices || const isActive = singleBeneficiary.hasDevices ||
singleBeneficiary.equipmentStatus === 'active' || singleBeneficiary.equipmentStatus === 'active' ||
singleBeneficiary.equipmentStatus === 'demo'; singleBeneficiary.equipmentStatus === 'demo';
@ -130,7 +138,14 @@ export const NavigationController = {
/** /**
* Determine where to navigate for beneficiary equipment setup * Determine where to navigate for beneficiary equipment setup
*/ */
getRouteForBeneficiarySetup(beneficiary: Beneficiary): NavigationResult { getRouteForBeneficiarySetup(beneficiary: Beneficiary | null | undefined): NavigationResult {
// Null safety: validate beneficiary exists and has valid id
if (!beneficiary || typeof beneficiary.id !== 'number') {
return {
path: ROUTES.AUTH.ADD_LOVED_ONE,
};
}
const status = beneficiary.equipmentStatus || 'none'; const status = beneficiary.equipmentStatus || 'none';
switch (status) { switch (status) {
@ -179,9 +194,16 @@ export const NavigationController = {
* @param hasExistingDevices - User indicated they already have WellNuo devices * @param hasExistingDevices - User indicated they already have WellNuo devices
*/ */
getRouteAfterAddBeneficiary( getRouteAfterAddBeneficiary(
beneficiaryId: number, beneficiaryId: number | null | undefined,
hasExistingDevices: boolean hasExistingDevices: boolean
): NavigationResult { ): NavigationResult {
// Null safety: validate beneficiary ID
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
return {
path: ROUTES.AUTH.ADD_LOVED_ONE,
};
}
if (hasExistingDevices) { if (hasExistingDevices) {
// User has existing devices - go directly to activation // User has existing devices - go directly to activation
return { return {
@ -201,10 +223,17 @@ export const NavigationController = {
* After successful purchase, navigate to next step * After successful purchase, navigate to next step
*/ */
getRouteAfterPurchase( getRouteAfterPurchase(
beneficiaryId: number, beneficiaryId: number | null | undefined,
purchaseResult: { skipToActivate?: boolean; demo?: boolean } purchaseResult: { skipToActivate?: boolean; demo?: boolean } | null | undefined
): NavigationResult { ): NavigationResult {
if (purchaseResult.demo || purchaseResult.skipToActivate) { // Null safety: validate beneficiary ID
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
return {
path: ROUTES.AUTH.ADD_LOVED_ONE,
};
}
if (purchaseResult?.demo || purchaseResult?.skipToActivate) {
// Demo mode or skip - go to activate // Demo mode or skip - go to activate
return { return {
path: ROUTES.AUTH.ACTIVATE, path: ROUTES.AUTH.ACTIVATE,
@ -225,7 +254,14 @@ export const NavigationController = {
/** /**
* After successful activation, navigate to beneficiary detail page * After successful activation, navigate to beneficiary detail page
*/ */
getRouteAfterActivation(beneficiaryId: number): NavigationResult { getRouteAfterActivation(beneficiaryId: number | null | undefined): NavigationResult {
// Null safety: validate beneficiary ID, fallback to dashboard
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
return {
path: ROUTES.TABS.DASHBOARD,
};
}
return { return {
path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId), path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId),
params: { justActivated: true }, params: { justActivated: true },
@ -238,8 +274,8 @@ export const NavigationController = {
* Used when user logs in with existing account * Used when user logs in with existing account
*/ */
getRouteForReturningUser( getRouteForReturningUser(
profile: UserProfile, profile: UserProfile | null | undefined,
beneficiaries: Beneficiary[] beneficiaries: Beneficiary[] | null | undefined
): NavigationResult { ): NavigationResult {
// Same logic as after login, but could add welcome-back screen // Same logic as after login, but could add welcome-back screen
return this.getRouteAfterLogin(profile, beneficiaries); return this.getRouteAfterLogin(profile, beneficiaries);
@ -259,9 +295,16 @@ export const NavigationController = {
* Get route for a specific beneficiary action * Get route for a specific beneficiary action
*/ */
getBeneficiaryRoute( getBeneficiaryRoute(
beneficiary: Beneficiary, beneficiary: Beneficiary | null | undefined,
action: 'view' | 'subscription' | 'equipment' | 'share' action: 'view' | 'subscription' | 'equipment' | 'share'
): NavigationResult { ): NavigationResult {
// Null safety: validate beneficiary exists and has valid id
if (!beneficiary || typeof beneficiary.id !== 'number') {
return {
path: ROUTES.TABS.BENEFICIARIES,
};
}
switch (action) { switch (action) {
case 'view': case 'view':
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) }; return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
@ -279,7 +322,12 @@ export const NavigationController = {
/** /**
* Determine if beneficiary card should show "Setup Equipment" button * Determine if beneficiary card should show "Setup Equipment" button
*/ */
shouldShowEquipmentSetup(beneficiary: Beneficiary): boolean { shouldShowEquipmentSetup(beneficiary: Beneficiary | null | undefined): boolean {
// Null safety: return false if beneficiary is invalid
if (!beneficiary) {
return false;
}
return !beneficiary.hasDevices && return !beneficiary.hasDevices &&
beneficiary.equipmentStatus !== 'active' && beneficiary.equipmentStatus !== 'active' &&
beneficiary.equipmentStatus !== 'demo'; beneficiary.equipmentStatus !== 'demo';
@ -289,8 +337,13 @@ export const NavigationController = {
* Get call-to-action for beneficiary based on status * Get call-to-action for beneficiary based on status
*/ */
getBeneficiaryCallToAction( getBeneficiaryCallToAction(
beneficiary: Beneficiary beneficiary: Beneficiary | null | undefined
): { label: string; action: () => NavigationResult } | null { ): { label: string; action: () => NavigationResult } | null {
// Null safety: return null if beneficiary is invalid
if (!beneficiary || typeof beneficiary.id !== 'number') {
return null;
}
const status = beneficiary.equipmentStatus || 'none'; const status = beneficiary.equipmentStatus || 'none';
if (beneficiary.hasDevices || status === 'active' || status === 'demo') { if (beneficiary.hasDevices || status === 'active' || status === 'demo') {

View File

@ -0,0 +1,424 @@
/**
* NavigationController Null Safety Tests
*
* Tests for null/undefined handling and edge cases in navigation logic
*/
import { NavigationController, ROUTES } from '../NavigationController';
import type { Beneficiary, EquipmentStatus } from '@/types';
describe('NavigationController - Null Safety', () => {
describe('getRouteAfterLogin', () => {
it('should handle null profile', () => {
const result = NavigationController.getRouteAfterLogin(null, []);
expect(result.path).toBe(ROUTES.AUTH.ENTER_NAME);
});
it('should handle undefined profile', () => {
const result = NavigationController.getRouteAfterLogin(undefined, []);
expect(result.path).toBe(ROUTES.AUTH.ENTER_NAME);
});
it('should handle profile without firstName', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: null,
lastName: null,
phone: null,
};
const result = NavigationController.getRouteAfterLogin(profile, []);
expect(result.path).toBe(ROUTES.AUTH.ENTER_NAME);
});
it('should handle null beneficiaries array', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
const result = NavigationController.getRouteAfterLogin(profile, null);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaries array', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
const result = NavigationController.getRouteAfterLogin(profile, undefined);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle empty beneficiaries array', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
const result = NavigationController.getRouteAfterLogin(profile, []);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle beneficiary with invalid id', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
const beneficiaries = [
{
id: null as any,
name: 'Mom',
displayName: 'Mom',
status: 'online' as const,
},
];
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should navigate to dashboard for active beneficiary', () => {
const profile = {
id: 1,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
phone: null,
};
const beneficiaries: Beneficiary[] = [
{
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: true,
equipmentStatus: 'active',
},
];
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
expect(result.path).toBe(ROUTES.TABS.DASHBOARD);
});
});
describe('getRouteForBeneficiarySetup', () => {
it('should handle null beneficiary', () => {
const result = NavigationController.getRouteForBeneficiarySetup(null);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiary', () => {
const result = NavigationController.getRouteForBeneficiarySetup(undefined);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle beneficiary with invalid id', () => {
const beneficiary = {
id: null as any,
name: 'Mom',
displayName: 'Mom',
status: 'online' as const,
};
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should navigate to purchase for beneficiary with no equipment', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
equipmentStatus: 'none',
};
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
expect(result.path).toBe(ROUTES.AUTH.PURCHASE);
expect(result.params).toEqual({ beneficiaryId: 1 });
});
it('should navigate to equipment page for ordered status', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
equipmentStatus: 'ordered',
};
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
expect(result.path).toBe(ROUTES.BENEFICIARY.EQUIPMENT(1));
});
});
describe('getRouteAfterAddBeneficiary', () => {
it('should handle null beneficiaryId', () => {
const result = NavigationController.getRouteAfterAddBeneficiary(null, false);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaryId', () => {
const result = NavigationController.getRouteAfterAddBeneficiary(undefined, false);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle invalid beneficiaryId type', () => {
const result = NavigationController.getRouteAfterAddBeneficiary('invalid' as any, false);
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should navigate to purchase for new equipment', () => {
const result = NavigationController.getRouteAfterAddBeneficiary(1, false);
expect(result.path).toBe(ROUTES.AUTH.PURCHASE);
expect(result.params).toEqual({ beneficiaryId: 1 });
});
it('should navigate to activate for existing devices', () => {
const result = NavigationController.getRouteAfterAddBeneficiary(1, true);
expect(result.path).toBe(ROUTES.AUTH.ACTIVATE);
expect(result.params).toEqual({ beneficiaryId: 1 });
});
});
describe('getRouteAfterPurchase', () => {
it('should handle null beneficiaryId', () => {
const result = NavigationController.getRouteAfterPurchase(null, {});
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle undefined beneficiaryId', () => {
const result = NavigationController.getRouteAfterPurchase(undefined, {});
expect(result.path).toBe(ROUTES.AUTH.ADD_LOVED_ONE);
});
it('should handle null purchaseResult', () => {
const result = NavigationController.getRouteAfterPurchase(1, null);
expect(result.path).toBe(ROUTES.BENEFICIARY.EQUIPMENT(1));
});
it('should handle undefined purchaseResult', () => {
const result = NavigationController.getRouteAfterPurchase(1, undefined);
expect(result.path).toBe(ROUTES.BENEFICIARY.EQUIPMENT(1));
});
it('should navigate to activate for demo mode', () => {
const result = NavigationController.getRouteAfterPurchase(1, { demo: true });
expect(result.path).toBe(ROUTES.AUTH.ACTIVATE);
expect(result.params).toEqual({ beneficiaryId: 1, demo: true });
});
it('should navigate to equipment page for normal purchase', () => {
const result = NavigationController.getRouteAfterPurchase(1, {});
expect(result.path).toBe(ROUTES.BENEFICIARY.EQUIPMENT(1));
});
});
describe('getRouteAfterActivation', () => {
it('should handle null beneficiaryId', () => {
const result = NavigationController.getRouteAfterActivation(null);
expect(result.path).toBe(ROUTES.TABS.DASHBOARD);
});
it('should handle undefined beneficiaryId', () => {
const result = NavigationController.getRouteAfterActivation(undefined);
expect(result.path).toBe(ROUTES.TABS.DASHBOARD);
});
it('should handle invalid beneficiaryId type', () => {
const result = NavigationController.getRouteAfterActivation('invalid' as any);
expect(result.path).toBe(ROUTES.TABS.DASHBOARD);
});
it('should navigate to beneficiary detail for valid id', () => {
const result = NavigationController.getRouteAfterActivation(1);
expect(result.path).toBe(ROUTES.BENEFICIARY.DETAIL(1));
expect(result.params).toEqual({ justActivated: true });
});
});
describe('getBeneficiaryRoute', () => {
it('should handle null beneficiary', () => {
const result = NavigationController.getBeneficiaryRoute(null, 'view');
expect(result.path).toBe(ROUTES.TABS.BENEFICIARIES);
});
it('should handle undefined beneficiary', () => {
const result = NavigationController.getBeneficiaryRoute(undefined, 'view');
expect(result.path).toBe(ROUTES.TABS.BENEFICIARIES);
});
it('should handle beneficiary with invalid id', () => {
const beneficiary = {
id: null as any,
name: 'Mom',
displayName: 'Mom',
status: 'online' as const,
};
const result = NavigationController.getBeneficiaryRoute(beneficiary, 'view');
expect(result.path).toBe(ROUTES.TABS.BENEFICIARIES);
});
it('should navigate to view for valid beneficiary', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
};
const result = NavigationController.getBeneficiaryRoute(beneficiary, 'view');
expect(result.path).toBe(ROUTES.BENEFICIARY.DETAIL(1));
});
it('should navigate to subscription for valid beneficiary', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
};
const result = NavigationController.getBeneficiaryRoute(beneficiary, 'subscription');
expect(result.path).toBe(ROUTES.BENEFICIARY.SUBSCRIPTION(1));
});
});
describe('shouldShowEquipmentSetup', () => {
it('should return false for null beneficiary', () => {
const result = NavigationController.shouldShowEquipmentSetup(null);
expect(result).toBe(false);
});
it('should return false for undefined beneficiary', () => {
const result = NavigationController.shouldShowEquipmentSetup(undefined);
expect(result).toBe(false);
});
it('should return true for beneficiary without devices', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: false,
equipmentStatus: 'none',
};
const result = NavigationController.shouldShowEquipmentSetup(beneficiary);
expect(result).toBe(true);
});
it('should return false for beneficiary with devices', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: true,
equipmentStatus: 'active',
};
const result = NavigationController.shouldShowEquipmentSetup(beneficiary);
expect(result).toBe(false);
});
});
describe('getBeneficiaryCallToAction', () => {
it('should return null for null beneficiary', () => {
const result = NavigationController.getBeneficiaryCallToAction(null);
expect(result).toBe(null);
});
it('should return null for undefined beneficiary', () => {
const result = NavigationController.getBeneficiaryCallToAction(undefined);
expect(result).toBe(null);
});
it('should return null for beneficiary with invalid id', () => {
const beneficiary = {
id: null as any,
name: 'Mom',
displayName: 'Mom',
status: 'online' as const,
};
const result = NavigationController.getBeneficiaryCallToAction(beneficiary);
expect(result).toBe(null);
});
it('should return null for active beneficiary', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: true,
equipmentStatus: 'active',
};
const result = NavigationController.getBeneficiaryCallToAction(beneficiary);
expect(result).toBe(null);
});
it('should return setup action for beneficiary with no equipment', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: false,
equipmentStatus: 'none',
};
const result = NavigationController.getBeneficiaryCallToAction(beneficiary);
expect(result).not.toBe(null);
expect(result?.label).toBe('Setup Equipment');
});
it('should return track delivery action for ordered equipment', () => {
const beneficiary: Beneficiary = {
id: 1,
name: 'Mom',
displayName: 'Mom',
status: 'online',
hasDevices: false,
equipmentStatus: 'ordered',
};
const result = NavigationController.getBeneficiaryCallToAction(beneficiary);
expect(result).not.toBe(null);
expect(result?.label).toBe('Track Delivery');
});
});
describe('getEquipmentStatusText', () => {
it('should handle undefined status', () => {
const result = NavigationController.getEquipmentStatusText(undefined);
expect(result).toBe('Unknown');
});
it('should return correct text for each status', () => {
expect(NavigationController.getEquipmentStatusText('none')).toBe('No equipment');
expect(NavigationController.getEquipmentStatusText('ordered')).toBe('Equipment ordered');
expect(NavigationController.getEquipmentStatusText('shipped')).toBe('In transit');
expect(NavigationController.getEquipmentStatusText('delivered')).toBe('Ready to activate');
expect(NavigationController.getEquipmentStatusText('active')).toBe('Active');
expect(NavigationController.getEquipmentStatusText('demo')).toBe('Demo mode');
});
});
describe('getEquipmentStatusColor', () => {
it('should handle undefined status', () => {
const result = NavigationController.getEquipmentStatusColor(undefined);
expect(result).toBe('#888888'); // gray
});
it('should return correct color for each status', () => {
expect(NavigationController.getEquipmentStatusColor('none')).toBe('#888888');
expect(NavigationController.getEquipmentStatusColor('ordered')).toBe('#FFA500');
expect(NavigationController.getEquipmentStatusColor('shipped')).toBe('#007AFF');
expect(NavigationController.getEquipmentStatusColor('delivered')).toBe('#34C759');
expect(NavigationController.getEquipmentStatusColor('active')).toBe('#34C759');
expect(NavigationController.getEquipmentStatusColor('demo')).toBe('#9C27B0');
});
});
});