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:
parent
7d9e7e37bf
commit
deddd3d5bc
424
hooks/__tests__/useNavigationFlow.nullsafety.test.tsx
Normal file
424
hooks/__tests__/useNavigationFlow.nullsafety.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -21,24 +21,34 @@ export function useNavigationFlow() {
|
||||
/**
|
||||
* 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
|
||||
? { pathname: result.path, params: result.params }
|
||||
: result.path;
|
||||
|
||||
try {
|
||||
if (replace) {
|
||||
router.replace(href as any);
|
||||
} else {
|
||||
router.push(href as any);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useNavigationFlow] Navigation error:', error);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
/**
|
||||
* Navigate after successful login/OTP verification
|
||||
*/
|
||||
const navigateAfterLogin = useCallback((
|
||||
profile: UserProfile | null,
|
||||
beneficiaries: Beneficiary[]
|
||||
profile: UserProfile | null | undefined,
|
||||
beneficiaries: Beneficiary[] | null | undefined
|
||||
) => {
|
||||
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
|
||||
navigate(result, true); // replace to prevent going back to login
|
||||
@ -48,7 +58,7 @@ export function useNavigationFlow() {
|
||||
* Navigate after creating a new beneficiary
|
||||
*/
|
||||
const navigateAfterAddBeneficiary = useCallback((
|
||||
beneficiaryId: number,
|
||||
beneficiaryId: number | null | undefined,
|
||||
hasExistingDevices: boolean
|
||||
) => {
|
||||
const result = NavigationController.getRouteAfterAddBeneficiary(
|
||||
@ -62,8 +72,8 @@ export function useNavigationFlow() {
|
||||
* Navigate after purchase completion
|
||||
*/
|
||||
const navigateAfterPurchase = useCallback((
|
||||
beneficiaryId: number,
|
||||
options: { skipToActivate?: boolean; demo?: boolean } = {}
|
||||
beneficiaryId: number | null | undefined,
|
||||
options: { skipToActivate?: boolean; demo?: boolean } | null | undefined = {}
|
||||
) => {
|
||||
const result = NavigationController.getRouteAfterPurchase(beneficiaryId, options);
|
||||
navigate(result);
|
||||
@ -72,7 +82,7 @@ export function useNavigationFlow() {
|
||||
/**
|
||||
* Navigate after device activation
|
||||
*/
|
||||
const navigateAfterActivation = useCallback((beneficiaryId: number) => {
|
||||
const navigateAfterActivation = useCallback((beneficiaryId: number | null | undefined) => {
|
||||
const result = NavigationController.getRouteAfterActivation(beneficiaryId);
|
||||
navigate(result, true); // replace to prevent going back
|
||||
}, [navigate]);
|
||||
@ -81,7 +91,7 @@ export function useNavigationFlow() {
|
||||
* Navigate to beneficiary-specific screens
|
||||
*/
|
||||
const navigateToBeneficiary = useCallback((
|
||||
beneficiary: Beneficiary,
|
||||
beneficiary: Beneficiary | null | undefined,
|
||||
action: 'view' | 'subscription' | 'equipment' | 'share' = 'view'
|
||||
) => {
|
||||
const result = NavigationController.getBeneficiaryRoute(beneficiary, action);
|
||||
@ -91,7 +101,7 @@ export function useNavigationFlow() {
|
||||
/**
|
||||
* 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);
|
||||
navigate(result);
|
||||
}, [navigate]);
|
||||
@ -122,14 +132,26 @@ export function useNavigationFlow() {
|
||||
navigate({ path: ROUTES.AUTH.ADD_LOVED_ONE });
|
||||
}, [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({
|
||||
path: ROUTES.AUTH.PURCHASE,
|
||||
params: { beneficiaryId },
|
||||
});
|
||||
}, [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({
|
||||
path: ROUTES.AUTH.ACTIVATE,
|
||||
params: demo !== undefined ? { beneficiaryId, demo } : { beneficiaryId },
|
||||
|
||||
@ -86,8 +86,8 @@ export const NavigationController = {
|
||||
* 4. User has active beneficiary → dashboard
|
||||
*/
|
||||
getRouteAfterLogin(
|
||||
profile: UserProfile | null,
|
||||
beneficiaries: Beneficiary[]
|
||||
profile: UserProfile | null | undefined,
|
||||
beneficiaries: Beneficiary[] | null | undefined
|
||||
): NavigationResult {
|
||||
// Step 1: Check if user has name
|
||||
if (!profile?.firstName) {
|
||||
@ -112,6 +112,14 @@ export const NavigationController = {
|
||||
|
||||
// Step 4: Single beneficiary - check if needs setup
|
||||
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 ||
|
||||
singleBeneficiary.equipmentStatus === 'active' ||
|
||||
singleBeneficiary.equipmentStatus === 'demo';
|
||||
@ -130,7 +138,14 @@ export const NavigationController = {
|
||||
/**
|
||||
* 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';
|
||||
|
||||
switch (status) {
|
||||
@ -179,9 +194,16 @@ export const NavigationController = {
|
||||
* @param hasExistingDevices - User indicated they already have WellNuo devices
|
||||
*/
|
||||
getRouteAfterAddBeneficiary(
|
||||
beneficiaryId: number,
|
||||
beneficiaryId: number | null | undefined,
|
||||
hasExistingDevices: boolean
|
||||
): NavigationResult {
|
||||
// Null safety: validate beneficiary ID
|
||||
if (!beneficiaryId || typeof beneficiaryId !== 'number') {
|
||||
return {
|
||||
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasExistingDevices) {
|
||||
// User has existing devices - go directly to activation
|
||||
return {
|
||||
@ -201,10 +223,17 @@ export const NavigationController = {
|
||||
* After successful purchase, navigate to next step
|
||||
*/
|
||||
getRouteAfterPurchase(
|
||||
beneficiaryId: number,
|
||||
purchaseResult: { skipToActivate?: boolean; demo?: boolean }
|
||||
beneficiaryId: number | null | undefined,
|
||||
purchaseResult: { skipToActivate?: boolean; demo?: boolean } | null | undefined
|
||||
): 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
|
||||
return {
|
||||
path: ROUTES.AUTH.ACTIVATE,
|
||||
@ -225,7 +254,14 @@ export const NavigationController = {
|
||||
/**
|
||||
* 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 {
|
||||
path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId),
|
||||
params: { justActivated: true },
|
||||
@ -238,8 +274,8 @@ export const NavigationController = {
|
||||
* Used when user logs in with existing account
|
||||
*/
|
||||
getRouteForReturningUser(
|
||||
profile: UserProfile,
|
||||
beneficiaries: Beneficiary[]
|
||||
profile: UserProfile | null | undefined,
|
||||
beneficiaries: Beneficiary[] | null | undefined
|
||||
): NavigationResult {
|
||||
// Same logic as after login, but could add welcome-back screen
|
||||
return this.getRouteAfterLogin(profile, beneficiaries);
|
||||
@ -259,9 +295,16 @@ export const NavigationController = {
|
||||
* Get route for a specific beneficiary action
|
||||
*/
|
||||
getBeneficiaryRoute(
|
||||
beneficiary: Beneficiary,
|
||||
beneficiary: Beneficiary | null | undefined,
|
||||
action: 'view' | 'subscription' | 'equipment' | 'share'
|
||||
): NavigationResult {
|
||||
// Null safety: validate beneficiary exists and has valid id
|
||||
if (!beneficiary || typeof beneficiary.id !== 'number') {
|
||||
return {
|
||||
path: ROUTES.TABS.BENEFICIARIES,
|
||||
};
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'view':
|
||||
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
|
||||
@ -279,7 +322,12 @@ export const NavigationController = {
|
||||
/**
|
||||
* 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 &&
|
||||
beneficiary.equipmentStatus !== 'active' &&
|
||||
beneficiary.equipmentStatus !== 'demo';
|
||||
@ -289,8 +337,13 @@ export const NavigationController = {
|
||||
* Get call-to-action for beneficiary based on status
|
||||
*/
|
||||
getBeneficiaryCallToAction(
|
||||
beneficiary: Beneficiary
|
||||
beneficiary: Beneficiary | null | undefined
|
||||
): { 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';
|
||||
|
||||
if (beneficiary.hasDevices || status === 'active' || status === 'demo') {
|
||||
|
||||
424
services/__tests__/NavigationController.nullsafety.test.ts
Normal file
424
services/__tests__/NavigationController.nullsafety.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user