diff --git a/.DS_Store b/.DS_Store index 6917cd35..73924ec2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index ae6d0bbd..aabbcac6 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -276,4 +276,41 @@ jobs: run: | firebase appdistribution:distribute ./ResgridUnit-ios-adhoc.ipa --app ${{ secrets.FIREBASE_IOS_APP_ID }} --groups "testers" + - name: 📋 Extract Release Notes from PR Body + if: ${{ matrix.platform == 'android' }} + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -eo pipefail + # Grab lines after "## Release Notes" until the next header + RELEASE_NOTES="$(printf '%s\n' "$PR_BODY" \ + | awk 'f && /^## /{f=0} /^## Release Notes/{f=1; next} f')" + # Use a unique delimiter to write multiline into GITHUB_ENV + delimiter="EOF_$(date +%s)_$RANDOM" + { + echo "RELEASE_NOTES<<$delimiter" + printf '%s\n' "${RELEASE_NOTES:-No release notes provided.}" + echo "$delimiter" + } >> "$GITHUB_ENV" + + - name: 📋 Prepare Release Notes file + if: ${{ matrix.platform == 'android' }} + run: | + { + echo "## Version 7.${{ github.run_number }} - $(date +%Y-%m-%d)" + echo + printf '%s\n' "${RELEASE_NOTES:-No release notes provided.}" + } > RELEASE_NOTES.md + + - name: 📦 Create Release + if: ${{ matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'prod-apk') }} + uses: ncipollo/release-action@v1 + with: + tag: "7.${{ github.run_number }}" + commit: ${{ github.sha }} + makeLatest: true + allowUpdates: true + name: "7.${{ github.run_number }}" + artifacts: "./ResgridUnit-prod.apk" + bodyFile: "RELEASE_NOTES.md" diff --git a/app.config.ts b/app.config.ts index f0347b75..ad82b6e2 100644 --- a/app.config.ts +++ b/app.config.ts @@ -38,6 +38,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, assetBundlePatterns: ['**/*'], ios: { + icon: './assets/ios-icon.png', version: packageJSON.version, buildNumber: packageJSON.version, supportsTablet: true, diff --git a/assets/ios-icon.png b/assets/ios-icon.png new file mode 100644 index 00000000..f4f69101 Binary files /dev/null and b/assets/ios-icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index 2b130ad9..3f025d3c 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/env.js b/env.js index d917d6bb..606a7372 100644 --- a/env.js +++ b/env.js @@ -83,7 +83,6 @@ const client = z.object({ BASE_API_URL: z.string(), API_VERSION: z.string(), RESGRID_API_URL: z.string(), - CHANNEL_API_URL: z.string(), CHANNEL_HUB_NAME: z.string(), REALTIME_GEO_HUB_NAME: z.string(), LOGGING_KEY: z.string(), @@ -118,7 +117,6 @@ const _clientEnv = { BASE_API_URL: process.env.UNIT_BASE_API_URL || 'https://qaapi.resgrid.dev', API_VERSION: process.env.UNIT_API_VERSION || 'v4', RESGRID_API_URL: process.env.UNIT_RESGRID_API_URL || '/api/v4', - CHANNEL_API_URL: process.env.UNIT_CHANNEL_API_URL || 'https://qaevents.resgrid.dev/', CHANNEL_HUB_NAME: process.env.UNIT_CHANNEL_HUB_NAME || 'eventingHub', REALTIME_GEO_HUB_NAME: process.env.UNIT_REALTIME_GEO_HUB_NAME || 'geolocationHub', LOGGING_KEY: process.env.UNIT_LOGGING_KEY || '', diff --git a/jest-setup.ts b/jest-setup.ts index cc747e21..70210e77 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -155,3 +155,22 @@ jest.mock('nativewind', () => ({ })), __esModule: true, })); + +// Mock zod globally to avoid validation schema issues in tests +jest.mock('zod', () => ({ + z: { + object: jest.fn(() => ({ + parse: jest.fn((data) => data), + safeParse: jest.fn((data) => ({ success: true, data })), + })), + string: jest.fn(() => ({ + min: jest.fn(() => ({ + parse: jest.fn((data) => data), + safeParse: jest.fn((data) => ({ success: true, data })), + })), + parse: jest.fn((data) => data), + safeParse: jest.fn((data) => ({ success: true, data })), + })), + }, + __esModule: true, +})); diff --git a/src/app/call/new/__tests__/address-search.test.ts b/src/app/call/new/__tests__/address-search.test.ts index f02e06e6..2963e0ce 100644 --- a/src/app/call/new/__tests__/address-search.test.ts +++ b/src/app/call/new/__tests__/address-search.test.ts @@ -103,13 +103,21 @@ describe('Address Search Logic', () => { const mockConfig: GetConfigResultData = { GoogleMapsKey: 'test-api-key', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; beforeEach(() => { @@ -143,13 +151,21 @@ describe('Address Search Logic', () => { const configWithoutKey: GetConfigResultData = { GoogleMapsKey: '', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; const result = await performAddressSearch('123 Main St', configWithoutKey); diff --git a/src/app/call/new/__tests__/coordinates-search.test.tsx b/src/app/call/new/__tests__/coordinates-search.test.tsx index d81e8c92..9d3e09d9 100644 --- a/src/app/call/new/__tests__/coordinates-search.test.tsx +++ b/src/app/call/new/__tests__/coordinates-search.test.tsx @@ -113,13 +113,21 @@ describe('Coordinates Search Logic', () => { const mockConfig: GetConfigResultData = { GoogleMapsKey: 'test-api-key', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; beforeEach(() => { @@ -249,13 +257,21 @@ describe('Coordinates Search Logic', () => { const configWithoutKey: GetConfigResultData = { GoogleMapsKey: '', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; const result = await performCoordinatesSearch('40.7128, -74.0060', configWithoutKey); diff --git a/src/app/call/new/__tests__/plus-code-search.test.ts b/src/app/call/new/__tests__/plus-code-search.test.ts index 04aaf6bc..5bae52fb 100644 --- a/src/app/call/new/__tests__/plus-code-search.test.ts +++ b/src/app/call/new/__tests__/plus-code-search.test.ts @@ -76,13 +76,21 @@ describe('Plus Code Search Logic', () => { const mockConfig: GetConfigResultData = { GoogleMapsKey: 'test-api-key', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; beforeEach(() => { @@ -116,13 +124,21 @@ describe('Plus Code Search Logic', () => { const configWithoutKey: GetConfigResultData = { GoogleMapsKey: '', W3WKey: '', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; const result = await performPlusCodeSearch('849VCWC8+R9', configWithoutKey); diff --git a/src/app/call/new/__tests__/what3words.test.tsx b/src/app/call/new/__tests__/what3words.test.tsx index caf97494..b3a718dd 100644 --- a/src/app/call/new/__tests__/what3words.test.tsx +++ b/src/app/call/new/__tests__/what3words.test.tsx @@ -10,13 +10,21 @@ const mockedAxios = axios as jest.Mocked; const mockConfig: GetConfigResultData = { GoogleMapsKey: 'test-mapbox-key', W3WKey: 'test-api-key', + EventingUrl: '', LoggingKey: '', MapUrl: '', MapAttribution: '', OpenWeatherApiKey: '', + DirectionsMapKey: '', + PersonnelLocationStaleSeconds: 300, + UnitLocationStaleSeconds: 300, + PersonnelLocationMinMeters: 15, + UnitLocationMinMeters: 15, NovuBackendApiUrl: '', NovuSocketUrl: '', NovuApplicationId: '', + AnalyticsApiKey: '', + AnalyticsHost: '', }; // Mock the core store diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx new file mode 100644 index 00000000..6515f2b2 --- /dev/null +++ b/src/app/login/__tests__/index.test.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; + +import Login from '../index'; + +const mockPush = jest.fn(); + +// Mock expo-router +jest.mock('expo-router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Mock UI components +jest.mock('@/components/ui', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + FocusAwareStatusBar: () => React.createElement(View, { testID: 'focus-aware-status-bar' }), + }; +}); + +jest.mock('@/components/ui/modal', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + Modal: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'modal' }, children) : null), + ModalBackdrop: ({ children }: any) => React.createElement(View, { testID: 'modal-backdrop' }, children), + ModalContent: ({ children, className }: any) => React.createElement(View, { testID: 'modal-content', className }, children), + ModalHeader: ({ children }: any) => React.createElement(View, { testID: 'modal-header' }, children), + ModalBody: ({ children }: any) => React.createElement(View, { testID: 'modal-body' }, children), + ModalFooter: ({ children }: any) => React.createElement(View, { testID: 'modal-footer' }, children), + }; +}); + +jest.mock('@/components/ui/text', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + Text: ({ children, className }: any) => React.createElement(Text, { className }, children), + }; +}); + +jest.mock('@/components/ui/button', () => { + const React = require('react'); + const { TouchableOpacity, Text } = require('react-native'); + + return { + Button: ({ children, onPress, variant, size, action }: any) => + React.createElement(TouchableOpacity, { onPress, testID: 'button' }, children), + ButtonText: ({ children }: any) => React.createElement(Text, {}, children), + }; +}); + +jest.mock('@/components/settings/server-url-bottom-sheet', () => { + const React = require('react'); + const { View, TouchableOpacity } = require('react-native'); + + return { + ServerUrlBottomSheet: ({ isOpen, onClose }: any) => + isOpen ? React.createElement(View, { testID: 'server-url-bottom-sheet' }, + React.createElement(TouchableOpacity, { onPress: onClose, testID: 'close-server-url' }, 'Close') + ) : null, + }; +}); + +jest.mock('../login-form', () => { + const React = require('react'); + const { View, TouchableOpacity, Text } = require('react-native'); + + return { + LoginForm: ({ onSubmit, isLoading, error, onServerUrlPress }: any) => + React.createElement(View, { testID: 'login-form' }, [ + React.createElement(Text, { key: 'loading' }, isLoading ? 'Loading...' : 'Not Loading'), + error && React.createElement(Text, { key: 'error' }, error), + React.createElement(TouchableOpacity, { + key: 'submit', + onPress: () => onSubmit({ username: 'test', password: 'test' }), + testID: 'submit-login' + }, 'Submit'), + onServerUrlPress && React.createElement(TouchableOpacity, { + key: 'server-url', + onPress: onServerUrlPress, + testID: 'server-url-button' + }, 'Server URL'), + ]) + }; +}); + +// Mock hooks +const mockUseAuth = jest.fn(); +const mockUseAnalytics = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'login.errorModal.title': 'Error', + 'login.errorModal.message': 'Login failed', + 'login.errorModal.confirmButton': 'OK', + }; + return translations[key] || key; + }, + }), +})); + +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => mockUseAnalytics(), +})); + +jest.mock('@/lib/auth', () => ({ + useAuth: () => mockUseAuth(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Login', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Set default mock return values + mockUseAuth.mockReturnValue({ + login: jest.fn(), + status: 'idle', + error: null, + isAuthenticated: false, + }); + + mockUseAnalytics.mockReturnValue({ + trackEvent: jest.fn(), + }); + }); + + it('renders login form and server URL button', () => { + render(); + + expect(screen.getByTestId('login-form')).toBeTruthy(); + expect(screen.getByTestId('server-url-button')).toBeTruthy(); + }); + + it('opens server URL bottom sheet when server URL button is pressed', async () => { + render(); + + const serverUrlButton = screen.getByTestId('server-url-button'); + fireEvent.press(serverUrlButton); + + await waitFor(() => { + expect(screen.getByTestId('server-url-bottom-sheet')).toBeTruthy(); + }); + }); + + it('closes server URL bottom sheet when close is pressed', async () => { + render(); + + // Open the bottom sheet + const serverUrlButton = screen.getByTestId('server-url-button'); + fireEvent.press(serverUrlButton); + + await waitFor(() => { + expect(screen.getByTestId('server-url-bottom-sheet')).toBeTruthy(); + }); + + // Close the bottom sheet + const closeButton = screen.getByTestId('close-server-url'); + fireEvent.press(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId('server-url-bottom-sheet')).toBeNull(); + }); + }); + + it('shows error modal when status is error', () => { + mockUseAuth.mockReturnValue({ + login: jest.fn(), + status: 'error', + error: 'Invalid credentials', + isAuthenticated: false, + }); + + render(); + + expect(screen.getByTestId('modal')).toBeTruthy(); + expect(screen.getByText('Login failed')).toBeTruthy(); + }); + + it('redirects to app when authenticated', async () => { + mockUseAuth.mockReturnValue({ + login: jest.fn(), + status: 'signedIn', + error: null, + isAuthenticated: true, + }); + + render(); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/(app)'); + }); + }); + + it('tracks analytics when login view is rendered', () => { + const mockTrackEvent = jest.fn(); + mockUseAnalytics.mockReturnValue({ + trackEvent: mockTrackEvent, + }); + + render(); + + expect(mockTrackEvent).toHaveBeenCalledWith('login_view_rendered', { + hasError: false, + status: 'idle', + }); + }); + + it('calls login function when form is submitted', () => { + const mockLogin = jest.fn(); + mockUseAuth.mockReturnValue({ + login: mockLogin, + status: 'idle', + error: null, + isAuthenticated: false, + }); + + render(); + + const submitButton = screen.getByTestId('submit-login'); + fireEvent.press(submitButton); + + expect(mockLogin).toHaveBeenCalledWith({ username: 'test', password: 'test' }); + }); +}); diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/app/login/__tests__/login-form.test.tsx new file mode 100644 index 00000000..b7733d31 --- /dev/null +++ b/src/app/login/__tests__/login-form.test.tsx @@ -0,0 +1,352 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; +import { View, Text, TouchableOpacity, TextInput } from 'react-native'; + +// Mock the entire login-form module to replace the schema creation +jest.mock('../login-form', () => { + const React = require('react'); + const { View, Text, TouchableOpacity, TextInput } = require('react-native'); + + const MockLoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress }: any) => { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [showPassword, setShowPassword] = React.useState(false); + + const handleSubmit = () => { + onSubmit({ username, password }); + }; + + return ( + + Username + + Password + + + setShowPassword(!showPassword)} + > + {showPassword ? 'Hide' : 'Show'} + + + + {isLoading && } + {isLoading ? 'Signing in...' : 'Log in'} + + {onServerUrlPress && ( + + Server URL + + )} + {error && {error}} + + ); + }; + + return { + LoginForm: MockLoginForm, + }; +}); + +import { LoginForm } from '../login-form'; + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'login.username': 'Username', + 'login.password': 'Password', + 'login.username_placeholder': 'Enter username', + 'login.password_placeholder': 'Enter password', + 'login.login_button_loading': 'Signing in...', + 'login.password_incorrect': 'Incorrect password', + 'settings.server_url': 'Server URL', + 'form.required': 'This field is required', + }; + return translations[key] || key; + }, + }), +})); + +// Mock nativewind +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ + colorScheme: 'light', + }), +})); + +// Mock react-native-keyboard-controller +jest.mock('react-native-keyboard-controller', () => ({ + KeyboardAvoidingView: ({ children }: any) => children, +})); + +// Mock react-hook-form +jest.mock('react-hook-form', () => ({ + useForm: () => ({ + control: {}, + handleSubmit: (fn: any) => fn, + formState: { errors: {} }, + }), + Controller: ({ render }: any) => { + const fieldProps = { + field: { + onChange: jest.fn(), + onBlur: jest.fn(), + value: '', + }, + }; + return render(fieldProps); + }, +})); + +// Mock @hookform/resolvers/zod +jest.mock('@hookform/resolvers/zod', () => ({ + zodResolver: () => ({}), +})); + +// Mock React Native modules to avoid native module issues +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + + // Create a safe mock for Settings that won't try to access native modules + const mockSettings = { + get: jest.fn(), + set: jest.fn(), + watchKeys: jest.fn(() => ({ remove: jest.fn() })), + }; + + const mockKeyboard = { + dismiss: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + removeAllListeners: jest.fn(), + removeListener: jest.fn(), + }; + + // Don't spread the entire RN object to avoid including problematic native modules + return { + View: RN.View, + Text: RN.Text, + TextInput: RN.TextInput, + TouchableOpacity: RN.TouchableOpacity, + Image: RN.Image, + ActivityIndicator: RN.ActivityIndicator, + ScrollView: RN.ScrollView, + Platform: RN.Platform, + Dimensions: RN.Dimensions, + StyleSheet: RN.StyleSheet, + Alert: RN.Alert, + Keyboard: mockKeyboard, + Settings: mockSettings, + // Mock TurboModuleRegistry to prevent any native module access + TurboModuleRegistry: { + getEnforcing: jest.fn(() => ({})), + get: jest.fn(() => ({})), + }, + }; +}); + +// Mock UI components +jest.mock('@/components/ui', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + View: ({ children, className }: any) => React.createElement(View, { className }, children), + }; +}); + +jest.mock('@/components/ui/button', () => { + const React = require('react'); + const { TouchableOpacity, Text, ActivityIndicator } = require('react-native'); + + return { + Button: ({ children, onPress, className, variant, action }: any) => + React.createElement(TouchableOpacity, { onPress, testID: 'button', className }, children), + ButtonText: ({ children }: any) => React.createElement(Text, {}, children), + ButtonSpinner: ({ color }: any) => React.createElement(ActivityIndicator, { color, testID: 'button-spinner' }), + }; +}); + +jest.mock('@/components/ui/form-control', () => { + const React = require('react'); + const { View, Text } = require('react-native'); + + return { + FormControl: ({ children, isInvalid, className }: any) => + React.createElement(View, { className, testID: isInvalid ? 'form-control-invalid' : 'form-control' }, children), + FormControlLabel: ({ children }: any) => React.createElement(View, {}, children), + FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children), + FormControlError: ({ children }: any) => React.createElement(View, { testID: 'form-control-error' }, children), + FormControlErrorIcon: ({ as: IconComponent, className }: any) => + React.createElement(View, { testID: 'form-control-error-icon', className }), + FormControlErrorText: ({ children, className }: any) => + React.createElement(Text, { className, testID: 'form-control-error-text' }, children), + }; +}); + +jest.mock('@/components/ui/input', () => { + const React = require('react'); + const { View, TextInput, TouchableOpacity } = require('react-native'); + + return { + Input: ({ children }: any) => React.createElement(View, { testID: 'input' }, children), + InputField: ({ value, onChangeText, onBlur, onSubmitEditing, placeholder, type, ...props }: any) => + React.createElement(TextInput, { + value, + onChangeText, + onBlur, + onSubmitEditing, + placeholder, + secureTextEntry: type === 'password', + testID: 'input-field', + ...props + }), + InputSlot: ({ children, onPress }: any) => + React.createElement(TouchableOpacity, { onPress, testID: 'input-slot' }, children), + InputIcon: ({ as: IconComponent }: any) => + React.createElement(View, { testID: 'input-icon' }), + }; +}); + +jest.mock('@/components/ui/text', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + Text: ({ children, className }: any) => React.createElement(Text, { className }, children), + }; +}); + +// Mock lucide icons +jest.mock('lucide-react-native', () => ({ + AlertTriangle: () => null, + EyeIcon: () => null, + EyeOffIcon: () => null, +})); + +// Mock colors +jest.mock('@/constants/colors', () => ({ + light: { + neutral: { + 400: '#9CA3AF', + }, + }, +})); + +describe('LoginForm', () => { + const defaultProps = { + onSubmit: jest.fn(), + isLoading: false, + error: undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all form fields', () => { + render(); + + expect(screen.getByText('Username')).toBeTruthy(); + expect(screen.getByText('Password')).toBeTruthy(); + expect(screen.getByPlaceholderText('Enter username')).toBeTruthy(); + expect(screen.getByPlaceholderText('Enter password')).toBeTruthy(); + expect(screen.getByText('Log in')).toBeTruthy(); + }); + + it('renders server URL button when onServerUrlPress prop is provided', () => { + const onServerUrlPress = jest.fn(); + render(); + + expect(screen.getByText('Server URL')).toBeTruthy(); + }); + + it('does not render server URL button when onServerUrlPress prop is not provided', () => { + render(); + + expect(screen.queryByText('Server URL')).toBeNull(); + }); + + it('calls onServerUrlPress when server URL button is pressed', () => { + const onServerUrlPress = jest.fn(); + render(); + + const serverUrlButton = screen.getByText('Server URL').parent; + if (serverUrlButton) { + fireEvent.press(serverUrlButton); + expect(onServerUrlPress).toHaveBeenCalledTimes(1); + } + }); + + it('shows loading state when isLoading is true', () => { + render(); + + expect(screen.getByTestId('button-spinner')).toBeTruthy(); + expect(screen.getByText('Signing in...')).toBeTruthy(); + }); + + it('allows user to toggle password visibility', () => { + render(); + + const passwordField = screen.getByPlaceholderText('Enter password'); + const toggleButton = screen.getByTestId('input-slot'); + + // Initially should be secured + expect(passwordField.props.secureTextEntry).toBe(true); + + // Toggle visibility + fireEvent.press(toggleButton); + + // Re-query the password field and verify it's now visible + const updatedPasswordField = screen.getByPlaceholderText('Enter password'); + expect(updatedPasswordField.props.secureTextEntry).toBe(false); + }); + + it('calls onSubmit with form data when form is submitted', async () => { + const onSubmit = jest.fn(); + render(); + + const usernameField = screen.getByPlaceholderText('Enter username'); + const passwordField = screen.getByPlaceholderText('Enter password'); + const submitButton = screen.getByText('Log in').parent; + + fireEvent.changeText(usernameField, 'testuser'); + fireEvent.changeText(passwordField, 'testpass'); + + if (submitButton) { + fireEvent.press(submitButton); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'testuser', + password: 'testpass' + }) + ); + } + }); + + it('disables form when loading', () => { + render(); + + // When loading, the submit button should show loading state + expect(screen.getByTestId('button-spinner')).toBeTruthy(); + expect(screen.queryByText('Signing in...')).toBeTruthy(); + }); +}); diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 50e33003..8eaa2100 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginFormProps } from '@/app/login/login-form'; +import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet'; import { FocusAwareStatusBar } from '@/components/ui'; import { Button, ButtonText } from '@/components/ui/button'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; @@ -15,6 +16,7 @@ import { LoginForm } from './login-form'; export default function Login() { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [showServerUrl, setShowServerUrl] = useState(false); const { t } = useTranslation(); const { trackEvent } = useAnalytics(); const router = useRouter(); @@ -58,7 +60,7 @@ export default function Login() { return ( <> - + setShowServerUrl(true)} /> + + setShowServerUrl(false)} /> ); } diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 73c127e0..ce25d732 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -16,18 +16,22 @@ import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import colors from '@/constants/colors'; -const loginFormSchema = z.object({ - username: z - .string({ - required_error: 'Username is required', - }) - .min(3, 'Username must be at least 3 characters'), - password: z - .string({ - required_error: 'Password is required', - }) - .min(6, 'Password must be at least 6 characters'), -}); +// Function to create schema - makes it easier to mock for testing +const createLoginFormSchema = () => + z.object({ + username: z + .string({ + required_error: 'Username is required', + }) + .min(3, 'Username must be at least 3 characters'), + password: z + .string({ + required_error: 'Password is required', + }) + .min(6, 'Password must be at least 6 characters'), + }); + +const loginFormSchema = createLoginFormSchema(); export type FormType = z.infer; @@ -35,9 +39,10 @@ export type LoginFormProps = { onSubmit?: SubmitHandler; isLoading?: boolean; error?: string; + onServerUrlPress?: () => void; }; -export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined }: LoginFormProps) => { +export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => { const { colorScheme } = useColorScheme(); const { t } = useTranslation(); const { @@ -167,6 +172,12 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde Log in )} + + {onServerUrlPress && ( + + )} ); diff --git a/src/models/v4/configs/getConfigResultData.ts b/src/models/v4/configs/getConfigResultData.ts index 811ace41..4a702119 100644 --- a/src/models/v4/configs/getConfigResultData.ts +++ b/src/models/v4/configs/getConfigResultData.ts @@ -1,11 +1,19 @@ export class GetConfigResultData { public W3WKey: string = ''; public GoogleMapsKey: string = ''; + public EventingUrl: string = ''; public LoggingKey: string = ''; public MapUrl: string = ''; public MapAttribution: string = ''; public OpenWeatherApiKey: string = ''; + public DirectionsMapKey: string = ''; + public PersonnelLocationStaleSeconds: number = 300; + public UnitLocationStaleSeconds: number = 300; + public PersonnelLocationMinMeters: number = 15; + public UnitLocationMinMeters: number = 15; public NovuBackendApiUrl: string = ''; public NovuSocketUrl: string = ''; public NovuApplicationId: string = ''; + public AnalyticsApiKey: string = ''; + public AnalyticsHost: string = ''; } diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts new file mode 100644 index 00000000..a58217d4 --- /dev/null +++ b/src/services/__tests__/signalr.service.test.ts @@ -0,0 +1,609 @@ +import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; + +import { logger } from '@/lib/logging'; + +// Mock the env module +jest.mock('@/lib/env', () => ({ + Env: { + REALTIME_GEO_HUB_NAME: 'geolocationHub', + }, +})); + +// Mock the auth store +jest.mock('@/stores/auth/store', () => { + const mockRefreshAccessToken = jest.fn().mockResolvedValue(undefined); + const mockGetState = jest.fn(() => ({ + accessToken: 'mock-token', + refreshAccessToken: mockRefreshAccessToken, + })); + return { + __esModule: true, + default: { + getState: mockGetState, + }, + }; +}); + +import useAuthStore from '@/stores/auth/store'; +import { signalRService, SignalRHubConnectConfig, SignalRHubConfig } from '../signalr.service'; + +// Mock the dependencies +jest.mock('@microsoft/signalr'); +jest.mock('@/lib/logging'); + +const mockGetState = (useAuthStore as any).getState; +const mockRefreshAccessToken = jest.fn().mockResolvedValue(undefined); + +const mockHubConnectionBuilder = HubConnectionBuilder as jest.MockedClass; +const mockLogger = logger as jest.Mocked; + +describe('SignalRService', () => { + let mockConnection: jest.Mocked; + let mockBuilderInstance: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + // Clear SignalR service state + (signalRService as any).connections.clear(); + (signalRService as any).reconnectAttempts.clear(); + (signalRService as any).hubConfigs.clear(); + + // Mock HubConnection + mockConnection = { + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + invoke: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + onclose: jest.fn(), + onreconnecting: jest.fn(), + onreconnected: jest.fn(), + } as any; + + // Mock HubConnectionBuilder + mockBuilderInstance = { + withUrl: jest.fn().mockReturnThis(), + withAutomaticReconnect: jest.fn().mockReturnThis(), + configureLogging: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue(mockConnection), + } as any; + + mockHubConnectionBuilder.mockImplementation(() => mockBuilderInstance); + + // Reset refresh token mock + mockRefreshAccessToken.mockClear(); + mockRefreshAccessToken.mockResolvedValue(undefined); + + // Mock auth store + mockGetState.mockReturnValue({ + accessToken: 'mock-token', + refreshAccessToken: mockRefreshAccessToken, + }); + }); + + describe('connectToHubWithEventingUrl', () => { + const mockConfig: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1', 'method2'], + }; + + it('should connect to hub successfully', async () => { + await signalRService.connectToHubWithEventingUrl(mockConfig); + + expect(mockHubConnectionBuilder).toHaveBeenCalled(); + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/eventingHub', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) + ); + expect(mockBuilderInstance.withAutomaticReconnect).toHaveBeenCalledWith([0, 2000, 5000, 10000, 30000]); + expect(mockBuilderInstance.configureLogging).toHaveBeenCalledWith(LogLevel.Information); + expect(mockConnection.start).toHaveBeenCalled(); + }); + + it('should register all methods on connection', async () => { + await signalRService.connectToHubWithEventingUrl(mockConfig); + + expect(mockConnection.on).toHaveBeenCalledTimes(2); + expect(mockConnection.on).toHaveBeenCalledWith('method1', expect.any(Function)); + expect(mockConnection.on).toHaveBeenCalledWith('method2', expect.any(Function)); + }); + + it('should set up connection event handlers', async () => { + await signalRService.connectToHubWithEventingUrl(mockConfig); + + expect(mockConnection.onclose).toHaveBeenCalledWith(expect.any(Function)); + expect(mockConnection.onreconnecting).toHaveBeenCalledWith(expect.any(Function)); + expect(mockConnection.onreconnected).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should throw error if no access token is available', async () => { + mockGetState.mockReturnValue({ + accessToken: '', + refreshAccessToken: mockRefreshAccessToken, + }); + + await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow( + 'No authentication token available' + ); + }); + + it('should add trailing slash to EventingUrl if missing', async () => { + const configWithoutSlash: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com', + hubName: 'eventingHub', + methods: ['method1', 'method2'], + }; + + await signalRService.connectToHubWithEventingUrl(configWithoutSlash); + + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/eventingHub', + expect.any(Object), + ); + }); + + it('should use URL parameter for geolocation hub authentication', async () => { + // Create a geolocation config + const geoConfig: SignalRHubConnectConfig = { + name: 'geoHub', + eventingUrl: 'https://api.example.com/', + hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + methods: ['onPersonnelLocationUpdated'], + }; + + await signalRService.connectToHubWithEventingUrl(geoConfig); + + // Should connect with URL parameter instead of header auth + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/geolocationHub?access_token=mock-token', + {} + ); + }); + + it('should use header authentication for non-geolocation hubs', async () => { + const regularConfig: SignalRHubConnectConfig = { + name: 'regularHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1'], + }; + + await signalRService.connectToHubWithEventingUrl(regularConfig); + + // Should connect with header auth + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/eventingHub', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) + ); + }); + + it('should properly encode access token in URL for geolocation hub', async () => { + // Set up a token that needs encoding + mockGetState.mockReturnValue({ + accessToken: 'token with spaces & special chars', + refreshAccessToken: mockRefreshAccessToken, + }); + + const geoConfig: SignalRHubConnectConfig = { + name: 'geoHub', + eventingUrl: 'https://api.example.com/', + hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + methods: ['onPersonnelLocationUpdated'], + }; + + await signalRService.connectToHubWithEventingUrl(geoConfig); + + // Should properly encode the token in the URL + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/geolocationHub?access_token=token%20with%20spaces%20%26%20special%20chars', + {} + ); + }); + + it('should properly URI encode complex access tokens for geolocation hub', async () => { + // Set up a complex token with various characters that need encoding + mockGetState.mockReturnValue({ + accessToken: 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9+/=?#&', + refreshAccessToken: mockRefreshAccessToken, + }); + + const geoConfig: SignalRHubConnectConfig = { + name: 'geoHub', + eventingUrl: 'https://api.example.com/', + hubName: 'geolocationHub', + methods: ['onPersonnelLocationUpdated'], + }; + + await signalRService.connectToHubWithEventingUrl(geoConfig); + + // Should properly encode all special characters in the token + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/geolocationHub?access_token=Bearer%20eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9%2B%2F%3D%3F%23%26', + {} + ); + }); + + it('should handle URL with existing query parameters for geolocation hub', async () => { + const geoConfig: SignalRHubConnectConfig = { + name: 'geoHub', + eventingUrl: 'https://api.example.com/path?existing=param', + hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + methods: ['onPersonnelLocationUpdated'], + }; + + await signalRService.connectToHubWithEventingUrl(geoConfig); + + // Should append the hub to the path and merge access_token with existing query parameters + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/path/geolocationHub?existing=param&access_token=mock-token', + {} + ); + }); + + it('should not add extra trailing slash if EventingUrl already has one', async () => { + const configWithSlash: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1', 'method2'], + }; + + await signalRService.connectToHubWithEventingUrl(configWithSlash); + + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + 'https://api.example.com/eventingHub', + expect.any(Object), + ); + }); + + it('should throw error if EventingUrl is not provided', async () => { + const configWithoutUrl = { ...mockConfig, eventingUrl: '' }; + + await expect(signalRService.connectToHubWithEventingUrl(configWithoutUrl)).rejects.toThrow( + 'EventingUrl is required for SignalR connection' + ); + }); + + it('should not connect if already connected', async () => { + // Connect first time + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Reset mocks to verify second call behavior + jest.clearAllMocks(); + + // Try to connect again + await signalRService.connectToHubWithEventingUrl(mockConfig); + + expect(mockHubConnectionBuilder).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: `Already connected to hub: ${mockConfig.name}`, + }); + }); + + it('should handle connection errors gracefully', async () => { + const error = new Error('Connection failed'); + mockConnection.start.mockRejectedValue(error); + + await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow(error); + + expect(mockLogger.error).toHaveBeenCalledWith({ + message: `Failed to connect to hub: ${mockConfig.name}`, + context: { error }, + }); + }); + }); + + describe('connectToHub (legacy method)', () => { + const mockConfig: SignalRHubConfig = { + name: 'testHub', + url: 'https://api.example.com/hub', + methods: ['method1'], + }; + + it('should connect to hub successfully', async () => { + await signalRService.connectToHub(mockConfig); + + expect(mockHubConnectionBuilder).toHaveBeenCalled(); + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( + mockConfig.url, + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) + ); + expect(mockConnection.start).toHaveBeenCalled(); + }); + }); + + describe('disconnectFromHub', () => { + const mockConfig: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1'], + }; + + it('should disconnect from hub successfully', async () => { + // Connect first + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Then disconnect + await signalRService.disconnectFromHub(mockConfig.name); + + expect(mockConnection.stop).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: `Disconnected from hub: ${mockConfig.name}`, + }); + }); + + it('should handle disconnect errors gracefully', async () => { + const error = new Error('Disconnect failed'); + + // Connect first + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Mock stop to throw error + mockConnection.stop.mockRejectedValue(error); + + await expect(signalRService.disconnectFromHub(mockConfig.name)).rejects.toThrow(error); + + expect(mockLogger.error).toHaveBeenCalledWith({ + message: `Error disconnecting from hub: ${mockConfig.name}`, + context: { error }, + }); + }); + + it('should do nothing if hub is not connected', async () => { + await signalRService.disconnectFromHub('nonExistentHub'); + + expect(mockConnection.stop).not.toHaveBeenCalled(); + }); + }); + + describe('invoke', () => { + const mockConfig: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1'], + }; + + it('should invoke method on connected hub', async () => { + const methodData = { test: 'data' }; + + // Connect first + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Invoke method + await signalRService.invoke(mockConfig.name, 'testMethod', methodData); + + expect(mockConnection.invoke).toHaveBeenCalledWith('testMethod', methodData); + }); + + it('should handle invoke errors gracefully', async () => { + const error = new Error('Invoke failed'); + const methodData = { test: 'data' }; + + // Connect first + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Mock invoke to throw error + mockConnection.invoke.mockRejectedValue(error); + + await expect(signalRService.invoke(mockConfig.name, 'testMethod', methodData)).rejects.toThrow(error); + + expect(mockLogger.error).toHaveBeenCalledWith({ + message: `Error invoking method testMethod from hub: ${mockConfig.name}`, + context: { error }, + }); + }); + + it('should do nothing if hub is not connected', async () => { + await signalRService.invoke('nonExistentHub', 'testMethod', {}); + + expect(mockConnection.invoke).not.toHaveBeenCalled(); + }); + }); + + describe('disconnectAll', () => { + it('should disconnect all connected hubs', async () => { + const config1: SignalRHubConnectConfig = { + name: 'hub1', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1'], + }; + const config2: SignalRHubConnectConfig = { + name: 'hub2', + eventingUrl: 'https://api.example.com/', + hubName: 'geoHub', + methods: ['method2'], + }; + + // Connect to multiple hubs + await signalRService.connectToHubWithEventingUrl(config1); + await signalRService.connectToHubWithEventingUrl(config2); + + // Disconnect all + await signalRService.disconnectAll(); + + // Should have called stop on both connections + expect(mockConnection.stop).toHaveBeenCalledTimes(2); + }); + }); + + describe('event handling', () => { + const mockConfig: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['testMethod'], + }; + + it('should handle received messages and emit events', async () => { + const eventCallback = jest.fn(); + + // Set up event listener + signalRService.on('testMethod', eventCallback); + + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Get the registered callback for the method + const registeredCallback = mockConnection.on.mock.calls.find( + call => call[0] === 'testMethod' + )?.[1]; + + expect(registeredCallback).toBeDefined(); + + // Simulate receiving a message + const testData = { message: 'test' }; + registeredCallback!(testData); + + // Verify the event was emitted + expect(eventCallback).toHaveBeenCalledWith(testData); + }); + + it('should remove event listeners', () => { + const eventCallback = jest.fn(); + + signalRService.on('testEvent', eventCallback); + signalRService.off('testEvent', eventCallback); + + // Emit an event (this would be called internally) + signalRService['emit']('testEvent', { test: 'data' }); + + // Callback should not have been called + expect(eventCallback).not.toHaveBeenCalled(); + }); + }); + + describe('reconnection handling', () => { + const mockConfig: SignalRHubConnectConfig = { + name: 'testHub', + eventingUrl: 'https://api.example.com/', + hubName: 'eventingHub', + methods: ['method1'], + }; + + it('should attempt reconnection on connection close', async () => { + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Get the onclose callback + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + // Spy on the connectToHubWithEventingUrl method to track reconnection attempts + const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); + connectSpy.mockResolvedValue(); // Mock the reconnection call + + // Use fake timers for setTimeout + jest.useFakeTimers(); + + // Trigger connection close + onCloseCallback(); + + // Fast-forward time to trigger the setTimeout callback + jest.advanceTimersByTime(5000); + + // Wait for all promises to resolve + await jest.runAllTicks(); + + // Should have called refreshAccessToken + expect(mockRefreshAccessToken).toHaveBeenCalled(); + + // Should have called connectToHubWithEventingUrl for reconnection + expect(connectSpy).toHaveBeenCalledWith(mockConfig); + + jest.useRealTimers(); + connectSpy.mockRestore(); + }, 10000); + + it('should stop reconnection attempts after max attempts', async () => { + jest.useFakeTimers(); + + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Get the onclose callback + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + // Simulate multiple failed reconnection attempts + for (let i = 0; i < 6; i++) { + onCloseCallback(); + jest.advanceTimersByTime(5000); + await jest.runAllTicks(); + } + + // Should log max attempts reached error + expect(mockLogger.error).toHaveBeenCalledWith({ + message: `Max reconnection attempts reached for hub: ${mockConfig.name}`, + }); + + jest.useRealTimers(); + }); + + it('should reset reconnection attempts on successful reconnection', async () => { + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Get the onreconnected callback + const onReconnectedCallback = mockConnection.onreconnected.mock.calls[0][0]; + + // Trigger reconnection + onReconnectedCallback('new-connection-id'); + + expect(mockLogger.info).toHaveBeenCalledWith({ + message: `Reconnected to hub: ${mockConfig.name}`, + context: { connectionId: 'new-connection-id' }, + }); + }); + + it('should handle token refresh failure during reconnection', async () => { + jest.useFakeTimers(); + + // Setup refresh token to fail + mockRefreshAccessToken.mockRejectedValue(new Error('Token refresh failed')); + + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + // Get the onclose callback + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + // Spy on the connectToHubWithEventingUrl method to ensure it's not called when token refresh fails + const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); + connectSpy.mockResolvedValue(); + + // Trigger connection close + onCloseCallback(); + + // Fast-forward time to trigger the setTimeout callback + jest.advanceTimersByTime(5000); + + // Wait for all promises to resolve + await jest.runAllTicks(); + + // Should have attempted to refresh token + expect(mockRefreshAccessToken).toHaveBeenCalled(); + + // Should have logged the failure + expect(mockLogger.error).toHaveBeenCalledWith({ + message: `Failed to refresh token or reconnect to hub: ${mockConfig.name}`, + context: { error: expect.any(Error), attempts: 1 }, + }); + + // Should NOT have called connectToHubWithEventingUrl due to token refresh failure + expect(connectSpy).not.toHaveBeenCalled(); + + jest.useRealTimers(); + connectSpy.mockRestore(); + }); + }); +}); diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 4832a180..b2c0c8d0 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -10,6 +10,13 @@ export interface SignalRHubConfig { methods: string[]; } +export interface SignalRHubConnectConfig { + name: string; + eventingUrl: string; // Base EventingUrl from config (trailing slash will be added if missing) + hubName: string; + methods: string[]; +} + export interface SignalRMessage { type: string; data: unknown; @@ -18,6 +25,7 @@ export interface SignalRMessage { class SignalRService { private connections: Map = new Map(); private reconnectAttempts: Map = new Map(); + private hubConfigs: Map = new Map(); private readonly MAX_RECONNECT_ATTEMPTS = 5; private readonly RECONNECT_INTERVAL = 5000; // 5 seconds @@ -32,6 +40,123 @@ class SignalRService { return SignalRService.instance; } + public async connectToHubWithEventingUrl(config: SignalRHubConnectConfig): Promise { + try { + if (this.connections.has(config.name)) { + logger.info({ + message: `Already connected to hub: ${config.name}`, + }); + return; + } + + const token = useAuthStore.getState().accessToken; + if (!token) { + throw new Error('No authentication token available'); + } + + if (!config.eventingUrl) { + throw new Error('EventingUrl is required for SignalR connection'); + } + + // Parse the incoming eventingUrl into path and query components + const url = new URL(config.eventingUrl); + + // Append the hub name to the path (ensuring a single slash) + const pathWithHub = url.pathname.endsWith('/') ? `${url.pathname}${config.hubName}` : `${url.pathname}/${config.hubName}`; + + // Reassemble the URL with the hub in the path + let fullUrl = `${url.protocol}//${url.host}${pathWithHub}`; + + // For geolocation hub, add token as URL parameter instead of header + const isGeolocationHub = config.hubName === Env.REALTIME_GEO_HUB_NAME; + + // Merge existing query parameters with access_token if needed + const queryParams = new URLSearchParams(url.search); + if (isGeolocationHub) { + queryParams.set('access_token', token); + } + + // Add query string if there are any parameters + if (queryParams.toString()) { + // Manually encode to ensure spaces are encoded as %20 instead of + + const queryString = queryParams.toString().replace(/\+/g, '%20'); + fullUrl = `${fullUrl}?${queryString}`; + } + + logger.info({ + message: `Connecting to hub: ${config.name}`, + context: { config, fullUrl: isGeolocationHub ? fullUrl.replace(/access_token=[^&]+/, 'access_token=***') : fullUrl }, + }); + + // Store the config for potential reconnections + this.hubConfigs.set(config.name, config); + + const connectionBuilder = new HubConnectionBuilder() + .withUrl( + fullUrl, + isGeolocationHub + ? {} + : { + accessTokenFactory: () => token, + } + ) + .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) + .configureLogging(LogLevel.Information); + + const connection = connectionBuilder.build(); + + // Set up event handlers + connection.onclose(() => { + this.handleConnectionClose(config.name); + }); + + connection.onreconnecting((error) => { + logger.warn({ + message: `Reconnecting to hub: ${config.name}`, + context: { error }, + }); + }); + + connection.onreconnected((connectionId) => { + logger.info({ + message: `Reconnected to hub: ${config.name}`, + context: { connectionId }, + }); + this.reconnectAttempts.set(config.name, 0); + }); + + // Register all methods + config.methods.forEach((method) => { + logger.info({ + message: `Registering ${method} message from hub: ${config.name}`, + context: { method }, + }); + + connection.on(method, (data) => { + logger.info({ + message: `Received ${method} message from hub: ${config.name}`, + context: { method, data }, + }); + this.handleMessage(config.name, method, data); + }); + }); + + await connection.start(); + this.connections.set(config.name, connection); + this.reconnectAttempts.set(config.name, 0); + + logger.info({ + message: `Connected to hub: ${config.name}`, + }); + } catch (error) { + logger.error({ + message: `Failed to connect to hub: ${config.name}`, + context: { error }, + }); + throw error; + } + } + public async connectToHub(config: SignalRHubConfig): Promise { try { if (this.connections.has(config.name)) { @@ -115,13 +240,46 @@ class SignalRService { const attempts = this.reconnectAttempts.get(hubName) || 0; if (attempts < this.MAX_RECONNECT_ATTEMPTS) { this.reconnectAttempts.set(hubName, attempts + 1); - setTimeout(() => { - this.connectToHub({ - name: hubName, - url: `${Env.CHANNEL_API_URL}${hubName}`, - methods: [], // You'll need to provide the methods when reconnecting + const currentAttempts = attempts + 1; + + const hubConfig = this.hubConfigs.get(hubName); + if (hubConfig) { + setTimeout(async () => { + try { + // Refresh authentication token before reconnecting + logger.info({ + message: `Refreshing authentication token before reconnecting to hub: ${hubName}`, + }); + + await useAuthStore.getState().refreshAccessToken(); + + // Verify we have a valid token after refresh + const token = useAuthStore.getState().accessToken; + if (!token) { + throw new Error('No valid authentication token available after refresh'); + } + + logger.info({ + message: `Token refreshed successfully, attempting to reconnect to hub: ${hubName}`, + }); + + await this.connectToHubWithEventingUrl(hubConfig); + } catch (error) { + logger.error({ + message: `Failed to refresh token or reconnect to hub: ${hubName}`, + context: { error, attempts: currentAttempts }, + }); + + // Don't attempt reconnection if token refresh failed + // The next reconnection attempt will be handled by the next connection close event + // if the token becomes available again + } + }, this.RECONNECT_INTERVAL); + } else { + logger.error({ + message: `No stored config found for hub: ${hubName}`, }); - }, this.RECONNECT_INTERVAL); + } } else { logger.error({ message: `Max reconnection attempts reached for hub: ${hubName}`, @@ -145,6 +303,7 @@ class SignalRService { await connection.stop(); this.connections.delete(hubName); this.reconnectAttempts.delete(hubName); + this.hubConfigs.delete(hubName); logger.info({ message: `Disconnected from hub: ${hubName}`, }); diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 88383588..7f358bcd 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -61,9 +61,12 @@ jest.mock('@/lib/storage', () => ({ // Import after mocks import { useCoreStore } from '../core-store'; import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app'; +import { getConfig } from '@/api/config/config'; +import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction; const mockGetActiveCallId = getActiveCallId as jest.MockedFunction; +const mockGetConfig = getConfig as jest.MockedFunction; describe('Core Store', () => { beforeEach(() => { @@ -92,6 +95,12 @@ describe('Core Store', () => { it('should prevent multiple simultaneous initializations', async () => { mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://eventing.example.com/', + GoogleMapsKey: 'test-key', + } as GetConfigResultData, + } as any); const { result } = renderHook(() => useCoreStore()); @@ -109,11 +118,20 @@ describe('Core Store', () => { // Should be initialized only once expect(result.current.isInitialized).toBe(true); expect(result.current.isInitializing).toBe(false); + expect(result.current.config).toEqual({ + EventingUrl: 'https://eventing.example.com/', + GoogleMapsKey: 'test-key', + }); }); it('should skip initialization if already initialized', async () => { mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://eventing.example.com/', + } as GetConfigResultData, + } as any); const { result } = renderHook(() => useCoreStore()); @@ -124,6 +142,9 @@ describe('Core Store', () => { expect(result.current.isInitialized).toBe(true); + // Clear mock to verify second call doesn't happen + jest.clearAllMocks(); + // Second initialization should skip await act(async () => { await result.current.init(); @@ -131,11 +152,46 @@ describe('Core Store', () => { expect(result.current.isInitialized).toBe(true); expect(result.current.isInitializing).toBe(false); + expect(mockGetConfig).not.toHaveBeenCalled(); }); it('should handle initialization with no active unit or call', async () => { mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: 'https://eventing.example.com/', + } as GetConfigResultData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.init(); + }); + + expect(result.current.isInitialized).toBe(true); + expect(result.current.isInitializing).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.config).toEqual({ + EventingUrl: 'https://eventing.example.com/', + }); + expect(mockGetConfig).toHaveBeenCalledTimes(1); + }); + + it('should fetch config first during initialization', async () => { + mockGetActiveUnitId.mockReturnValue(null); + mockGetActiveCallId.mockReturnValue(null); + + const mockConfigData = { + EventingUrl: 'https://eventing.example.com/', + GoogleMapsKey: 'test-google-key', + OpenWeatherApiKey: 'test-weather-key', + } as GetConfigResultData; + + mockGetConfig.mockResolvedValue({ + Data: mockConfigData, + } as any); const { result } = renderHook(() => useCoreStore()); @@ -143,10 +199,93 @@ describe('Core Store', () => { await result.current.init(); }); + expect(mockGetConfig).toHaveBeenCalledTimes(1); + expect(result.current.config).toEqual(mockConfigData); expect(result.current.isInitialized).toBe(true); + expect(result.current.error).toBe(null); + }); + + it('should handle config fetch errors during initialization', async () => { + mockGetActiveUnitId.mockReturnValue(null); + mockGetActiveCallId.mockReturnValue(null); + + const configError = new Error('Failed to fetch config'); + mockGetConfig.mockRejectedValue(configError); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.init(); + }); + + expect(result.current.isInitialized).toBe(false); expect(result.current.isInitializing).toBe(false); + expect(result.current.error).toBe('Failed to init core app data'); + expect(result.current.config).toBe(null); + }); + }); + + describe('Config Management', () => { + it('should fetch config successfully', async () => { + const mockConfigData = { + EventingUrl: 'https://eventing.example.com/', + GoogleMapsKey: 'test-google-key', + MapUrl: 'https://maps.example.com/', + LoggingKey: 'test-logging-key', + } as GetConfigResultData; + + mockGetConfig.mockResolvedValue({ + Data: mockConfigData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.fetchConfig(); + }); + + expect(mockGetConfig).toHaveBeenCalledTimes(1); + expect(result.current.config).toEqual(mockConfigData); expect(result.current.error).toBe(null); }); + + it('should handle config fetch errors', async () => { + const configError = new Error('Config service unavailable'); + mockGetConfig.mockRejectedValue(configError); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + try { + await result.current.fetchConfig(); + } catch (error) { + // Expected to throw since fetchConfig re-throws the error + expect(error).toBe(configError); + } + }); + + expect(result.current.config).toBe(null); + expect(result.current.error).toBe('Failed to fetch config'); + expect(result.current.isLoading).toBe(false); + }); + + it('should provide EventingUrl for SignalR connections', async () => { + const eventingUrl = 'https://eventing.resgrid.com/'; + mockGetConfig.mockResolvedValue({ + Data: { + EventingUrl: eventingUrl, + GoogleMapsKey: 'test-key', + } as GetConfigResultData, + } as any); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await result.current.fetchConfig(); + }); + + expect(result.current.config?.EventingUrl).toBe(eventingUrl); + }); }); describe('Store State', () => { diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index d6c32946..13bd1a29 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -83,6 +83,14 @@ export const useCoreStore = create()( set({ isLoading: true, isInitializing: true, error: null }); try { + // Fetch config first before anything else - this is critical for SignalR connections + await get().fetchConfig(); + + // If config fetch failed, don't continue initialization + if (get().error) { + throw new Error('Config fetch failed, cannot continue initialization'); + } + const activeUnitId = getActiveUnitId(); const activeCallId = getActiveCallId(); @@ -236,13 +244,14 @@ export const useCoreStore = create()( fetchConfig: async () => { try { const config = await getConfig(Env.APP_KEY); - set({ config: config.Data }); + set({ config: config.Data, error: null }); } catch (error) { set({ error: 'Failed to fetch config', isLoading: false }); logger.error({ message: `Failed to fetch config: ${JSON.stringify(error)}`, context: { error }, }); + throw error; // Re-throw to allow calling code to handle } }, }), diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts new file mode 100644 index 00000000..308a91c6 --- /dev/null +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -0,0 +1,284 @@ +import { act, renderHook } from '@testing-library/react-native'; + +// Create the mock before any imports +const mockCoreStoreGetState = jest.fn(() => ({ + config: { + EventingUrl: 'https://eventing.example.com/', + }, +})); + +const mockSecurityStore = { + getState: jest.fn(() => ({ + rights: { + DepartmentId: '123', + }, + })), +}; + +// Mock all dependencies before importing anything +jest.mock('@/services/signalr.service', () => { + const mockInstance = { + connectToHubWithEventingUrl: jest.fn().mockResolvedValue(undefined), + disconnectFromHub: jest.fn().mockResolvedValue(undefined), + invoke: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + connectToHub: jest.fn().mockResolvedValue(undefined), + disconnectAll: jest.fn().mockResolvedValue(undefined), + }; + return { + signalRService: mockInstance, + default: mockInstance, + }; +}); + +// Mock the core store module directly - mock as a function that behaves like a Zustand store +jest.mock('../../app/core-store', () => { + const createMockStore = () => { + const mockStore = () => mockCoreStoreGetState(); + // Ensure getState always calls the current mock function + mockStore.getState = () => mockCoreStoreGetState(); + mockStore.subscribe = jest.fn(); + mockStore.setState = jest.fn(); + mockStore.destroy = jest.fn(); + + return mockStore; + }; + + return { + useCoreStore: createMockStore(), + }; +}); + +jest.mock('@/stores/security/store', () => ({ + securityStore: mockSecurityStore, +})); + +jest.mock('../../security/store', () => ({ + securityStore: mockSecurityStore, + useSecurityStore: mockSecurityStore, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + fatal: jest.fn(), + }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { + CHANNEL_HUB_NAME: 'eventingHub', + REALTIME_GEO_HUB_NAME: 'geolocationHub', + }, +})); + +jest.mock('@/lib', () => ({ + useAuthStore: { + getState: jest.fn(() => ({ + accessToken: 'mock-token', + })), + }, +})); + +// Import the store after all mocks are set up +import { useSignalRStore } from '../signalr-store'; +import { logger } from '@/lib/logging'; +import { signalRService } from '@/services/signalr.service'; + +describe('useSignalRStore', () => { + const mockEventingUrl = 'https://eventing.example.com/'; + const mockDepartmentId = '123'; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset the mock function to default behavior + mockCoreStoreGetState.mockReturnValue({ + config: { + EventingUrl: mockEventingUrl, + }, + }); + + // Mock security store + mockSecurityStore.getState.mockReturnValue({ + rights: { + DepartmentId: mockDepartmentId, + }, + } as any); + + // Mock SignalR service methods + (signalRService.connectToHubWithEventingUrl as jest.Mock).mockResolvedValue(undefined); + (signalRService.disconnectFromHub as jest.Mock).mockResolvedValue(undefined); + (signalRService.invoke as jest.Mock).mockResolvedValue(undefined); + (signalRService.on as jest.Mock).mockImplementation(() => {}); + }); + + describe('Basic Store Functionality', () => { + it('should create a store instance with correct initial state', () => { + const { result } = renderHook(() => useSignalRStore()); + + expect(result.current).toBeDefined(); + expect(typeof result.current.connectUpdateHub).toBe('function'); + expect(typeof result.current.disconnectUpdateHub).toBe('function'); + expect(typeof result.current.connectGeolocationHub).toBe('function'); + expect(typeof result.current.disconnectGeolocationHub).toBe('function'); + + expect(result.current.isUpdateHubConnected).toBe(false); + expect(result.current.isGeolocationHubConnected).toBe(false); + expect(result.current.lastUpdateMessage).toBeNull(); + expect(result.current.lastGeolocationMessage).toBeNull(); + expect(result.current.lastUpdateTimestamp).toBe(0); + expect(result.current.lastGeolocationTimestamp).toBe(0); + expect(result.current.error).toBeNull(); + }); + }); + + describe('connectUpdateHub', () => { + it('should handle missing EventingUrl', async () => { + // Mock core store without EventingUrl + mockCoreStoreGetState.mockReturnValue({ + config: { + EventingUrl: undefined, + } as any, + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); + expect(result.current.error).toEqual( + new Error('EventingUrl not available in config. Please ensure config is loaded first.') + ); + + expect(logger.error).toHaveBeenCalledWith({ + message: 'EventingUrl not available in config. Please ensure config is loaded first.', + }); + }); + + it('should handle missing config', async () => { + // Mock core store without config + mockCoreStoreGetState.mockReturnValue({ + config: null as any, + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); + expect(result.current.error).toEqual( + new Error('EventingUrl not available in config. Please ensure config is loaded first.') + ); + }); + + it('should handle connection errors', async () => { + const connectionError = new Error('Connection failed'); + (signalRService.connectToHubWithEventingUrl as jest.Mock).mockRejectedValue(connectionError); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(result.current.error).toEqual(connectionError); + expect(logger.error).toHaveBeenCalledWith({ + message: 'Failed to connect to SignalR hubs', + context: { error: connectionError }, + }); + }); + }); + + describe('disconnectUpdateHub', () => { + it('should disconnect from update hub successfully', async () => { + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.disconnectUpdateHub(); + }); + + expect(signalRService.disconnectFromHub).toHaveBeenCalledWith('eventingHub'); + expect(result.current.isUpdateHubConnected).toBe(false); + expect(result.current.lastUpdateMessage).toBeNull(); + }); + + it('should handle disconnect errors', async () => { + const disconnectError = new Error('Disconnect failed'); + (signalRService.disconnectFromHub as jest.Mock).mockRejectedValue(disconnectError); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.disconnectUpdateHub(); + }); + + expect(result.current.error).toEqual(disconnectError); + expect(logger.error).toHaveBeenCalledWith({ + message: 'Failed to disconnect from SignalR hubs', + context: { error: disconnectError }, + }); + }); + }); + + describe('connectGeolocationHub', () => { + it('should handle missing EventingUrl', async () => { + // Mock core store without EventingUrl + mockCoreStoreGetState.mockReturnValue({ + config: { + EventingUrl: undefined, + } as any, + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); + expect(result.current.error).toEqual( + new Error('EventingUrl not available in config. Please ensure config is loaded first.') + ); + }); + }); + + describe('disconnectGeolocationHub', () => { + it('should disconnect from geolocation hub successfully', async () => { + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.disconnectGeolocationHub(); + }); + + expect(signalRService.disconnectFromHub).toHaveBeenCalledWith('geolocationHub'); + expect(result.current.isGeolocationHubConnected).toBe(false); + expect(result.current.lastGeolocationMessage).toBeNull(); + }); + + it('should handle disconnect errors', async () => { + const disconnectError = new Error('Geolocation disconnect failed'); + (signalRService.disconnectFromHub as jest.Mock).mockRejectedValue(disconnectError); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.disconnectGeolocationHub(); + }); + + expect(result.current.error).toEqual(disconnectError); + expect(logger.error).toHaveBeenCalledWith({ + message: 'Failed to disconnect from SignalR hubs', + context: { error: disconnectError }, + }); + }); + }); +}); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 1e046eca..f0dc9cae 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -38,10 +38,24 @@ export const useSignalRStore = create((set, get) => ({ set({ isUpdateHubConnected: false, error: null }); + // Get the eventing URL from the core store config + const coreState = useCoreStore.getState(); + const eventingUrl = coreState.config?.EventingUrl; + + if (!eventingUrl) { + const errorMessage = 'EventingUrl not available in config. Please ensure config is loaded first.'; + logger.error({ + message: errorMessage, + }); + set({ error: new Error(errorMessage) }); + return; + } + // Connect to the eventing hub - await signalRService.connectToHub({ + await signalRService.connectToHubWithEventingUrl({ name: Env.CHANNEL_HUB_NAME, - url: `${Env.CHANNEL_API_URL}${Env.CHANNEL_HUB_NAME}`, + eventingUrl: eventingUrl, + hubName: Env.CHANNEL_HUB_NAME, methods: ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed', 'onConnected'], }); @@ -133,10 +147,24 @@ export const useSignalRStore = create((set, get) => ({ set({ isGeolocationHubConnected: false, error: null }); + // Get the eventing URL from the core store config + const coreState = useCoreStore.getState(); + const eventingUrl = coreState.config?.EventingUrl; + + if (!eventingUrl) { + const errorMessage = 'EventingUrl not available in config. Please ensure config is loaded first.'; + logger.error({ + message: errorMessage, + }); + set({ error: new Error(errorMessage) }); + return; + } + // Connect to the geolocation hub - await signalRService.connectToHub({ + await signalRService.connectToHubWithEventingUrl({ name: Env.REALTIME_GEO_HUB_NAME, - url: `${Env.CHANNEL_API_URL}${Env.REALTIME_GEO_HUB_NAME}`, + eventingUrl: eventingUrl, + hubName: Env.REALTIME_GEO_HUB_NAME, methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], });