Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/__tests__/app/call/[id].test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ jest.mock('react-native-webview', () => ({
default: 'WebView',
}));

// Mock react-native-restart - pulled in transitively via the i18n utils used by the
// chat store; the native module is absent under jest
jest.mock('react-native-restart', () => ({
__esModule: true,
default: {
Restart: jest.fn(),
},
}));

jest.mock('@/hooks/use-analytics', () => ({
useAnalytics: jest.fn(),
}));
Expand Down
45 changes: 25 additions & 20 deletions src/__tests__/app/calls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,33 @@ const mockCallsStore = {
};

const mockSecurityStore = {
canUserCreateCalls: true,
rights: { CanCreateCalls: true } as { CanCreateCalls: boolean } | undefined,
};

const mockAnalytics = {
trackEvent: jest.fn(),
};

// Mock the stores with proper getState method
// Mock the stores with proper getState method. The hook mocks apply an optional
// selector, matching how zustand hooks are called with field selectors.
jest.mock('@/stores/calls/store', () => {
const useCallsStore = jest.fn(() => mockCallsStore);
const useCallsStore = jest.fn((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
(useCallsStore as any).getState = jest.fn(() => mockCallsStore);

return {
useCallsStore,
};
});

jest.mock('@/stores/security/store', () => ({
useSecurityStore: jest.fn(() => mockSecurityStore),
}));
jest.mock('@/stores/security/store', () => {
const securityStore = jest.fn((selector?: (state: typeof mockSecurityStore) => unknown) => (selector ? selector(mockSecurityStore) : mockSecurityStore));
(securityStore as any).getState = jest.fn(() => mockSecurityStore);

return {
securityStore,
useSecurityStore: jest.fn(() => ({ canUserCreateCalls: mockSecurityStore.rights?.CanCreateCalls })),
};
});

jest.mock('@/hooks/use-analytics', () => ({
useAnalytics: jest.fn(() => mockAnalytics),
Expand Down Expand Up @@ -216,9 +223,9 @@ describe('CallsScreen', () => {
beforeEach(() => {
jest.clearAllMocks();

// Reset mock returns to defaults
useCallsStore.mockReturnValue(mockCallsStore);
useSecurityStore.mockReturnValue(mockSecurityStore);
// Reset mock behavior to defaults (selector-aware, like the real zustand hooks)
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
useSecurityStore.mockImplementation(() => ({ canUserCreateCalls: mockSecurityStore.rights?.CanCreateCalls }));
useAnalytics.mockReturnValue(mockAnalytics);

// Reset the mock store state
Expand All @@ -227,13 +234,12 @@ describe('CallsScreen', () => {
mockCallsStore.error = null;
mockCallsStore.callPriorities = [];

mockSecurityStore.canUserCreateCalls = true;
mockSecurityStore.rights = { CanCreateCalls: true };
});

describe('when user has create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = true;
useSecurityStore.mockReturnValue(mockSecurityStore);
mockSecurityStore.rights = { CanCreateCalls: true };
});

it('renders the new call FAB button', () => {
Expand All @@ -244,7 +250,7 @@ describe('CallsScreen', () => {
expect(htmlContent).toBeTruthy();

// Since we can see the button in debug output, let's just verify the mock is working
expect(mockSecurityStore.canUserCreateCalls).toBe(true);
expect(mockSecurityStore.rights?.CanCreateCalls).toBe(true);
});

it('navigates to new call screen when FAB is pressed', () => {
Expand All @@ -262,8 +268,7 @@ describe('CallsScreen', () => {

describe('when user does not have create calls permission', () => {
beforeEach(() => {
mockSecurityStore.canUserCreateCalls = false;
useSecurityStore.mockReturnValue(mockSecurityStore);
mockSecurityStore.rights = { CanCreateCalls: false };
});

it('does not render the new call FAB button', () => {
Expand Down Expand Up @@ -291,7 +296,7 @@ describe('CallsScreen', () => {

beforeEach(() => {
mockCallsStore.calls = mockCalls;
useCallsStore.mockReturnValue(mockCallsStore);
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
});

it('renders call cards for each call', () => {
Expand Down Expand Up @@ -333,7 +338,7 @@ describe('CallsScreen', () => {
describe('loading and error states', () => {
it('shows loading state when isLoading is true', () => {
mockCallsStore.isLoading = true;
useCallsStore.mockReturnValue(mockCallsStore);
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));

render(<CallsScreen />);

Expand All @@ -346,7 +351,7 @@ describe('CallsScreen', () => {

it('shows error state when there is an error', () => {
mockCallsStore.error = 'Network error';
useCallsStore.mockReturnValue(mockCallsStore);
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));

render(<CallsScreen />);

Expand All @@ -359,7 +364,7 @@ describe('CallsScreen', () => {

it('shows zero state when there are no calls', () => {
mockCallsStore.calls = [];
useCallsStore.mockReturnValue(mockCallsStore);
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));

render(<CallsScreen />);

Expand All @@ -383,7 +388,7 @@ describe('CallsScreen', () => {
it('tracks view rendered event with correct parameters', () => {
const mockCalls = [{ CallId: 'call-1', Nature: 'Test' }];
mockCallsStore.calls = mockCalls;
useCallsStore.mockReturnValue(mockCallsStore);
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));

render(<CallsScreen />);

Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/security-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ describe('Security Permission Logic', () => {
CanCreateCalls: true,
CanAddNote: false,
CanCreateMessage: false,
CanLoginToDispatchApp: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand All @@ -44,6 +46,8 @@ describe('Security Permission Logic', () => {
CanCreateCalls: false,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToDispatchApp: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand All @@ -65,6 +69,8 @@ describe('Security Permission Logic', () => {
CanViewPII: true,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToDispatchApp: true,
CanLoginToCommandApp: true,
Groups: []
} as unknown as DepartmentRightsResultData;

Expand All @@ -85,6 +91,8 @@ describe('Security Permission Logic', () => {
CanCreateCalls: true,
CanAddNote: false,
CanCreateMessage: false,
CanLoginToDispatchApp: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand All @@ -107,6 +115,8 @@ describe('Security Permission Logic', () => {
CanCreateCalls: false,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToDispatchApp: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand Down
20 changes: 18 additions & 2 deletions src/api/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,25 @@ const MODERATION = '/ChatModeration';
// Channels
// ---------------------------------------------------------------------------

export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => {
/**
* The caller's channels. `includeArchived` pulls in the point-in-time record of closed incidents and
* calls — off by default so the everyday list stays current. `callId` narrows the result server-side
* to channels attached to that call (older servers ignore it and return the full list).
*/
export const getChannels = async (activeUnitId?: number, includeArchived = false, callId?: number, signal?: AbortSignal) => {
const params: Record<string, unknown> = {};
if (activeUnitId != null) {
params.activeUnitId = activeUnitId;
}
if (includeArchived) {
params.includeArchived = true;
}
if (callId != null) {
params.callId = callId;
}

const response = await api.get<ChatV4Response<ChatChannelResultData[]>>(`${CHAT}/GetChannels`, {
params: activeUnitId != null ? { activeUnitId } : undefined,
params: Object.keys(params).length > 0 ? params : undefined,
signal,
});
Comment on lines 51 to 54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Uncaught network exception: The external HTTP call api.get lacks a try/catch with context mapping, violating rule [27]. Wrap the call to map exceptions to application-level errors using AppError.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/chat/chat.ts:

Line 47 to 50:

Uncaught network exception: The external HTTP call `api.get` lacks a try/catch with context mapping, violating rule [27]. Wrap the call to map exceptions to application-level errors using `AppError`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return response.data;
Expand Down
34 changes: 15 additions & 19 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';

import { refreshTokenRequest } from '@/lib/auth/api';
import { performTokenRefresh } from '@/lib/auth/token-refresh';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';
Expand Down Expand Up @@ -78,35 +78,31 @@ axiosInstance.interceptors.response.use(
isRefreshing = true;

try {
const refreshToken = useAuthStore.getState().refreshToken;
if (!refreshToken) {
throw new Error('No refresh token available');
// Single-flight refresh shared with the auth store's refresh timer, so a
// timer refresh and a 401-triggered refresh can never rotate the refresh
// token twice in parallel. Failure handling (logout) happens inside
// performTokenRefresh.
const refreshed = await performTokenRefresh();
if (!refreshed) {
throw new Error('Token refresh failed');
}

const response = await refreshTokenRequest(refreshToken);
const { access_token, refresh_token: newRefreshToken } = response;

// Update tokens in store
useAuthStore.setState({
accessToken: access_token,
refreshToken: newRefreshToken,
status: 'signedIn',
error: null,
});
const accessToken = useAuthStore.getState().accessToken;
if (!accessToken) {
throw new Error('No access token available after refresh');
}

// Update Authorization header
axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`;
originalRequest.headers.Authorization = `Bearer ${access_token}`;
axiosInstance.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
originalRequest.headers.Authorization = `Bearer ${accessToken}`;

processQueue(null);
return axiosInstance(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error);
// Handle refresh token failure
useAuthStore.getState().logout();
logger.error({
message: 'Token refresh failed',
context: { error: refreshError },
context: { error: refreshError instanceof Error ? refreshError.message : String(refreshError) },
});
return Promise.reject(refreshError);
} finally {
Expand Down
65 changes: 63 additions & 2 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,54 @@ import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
import { useIsFirstTime } from '@/lib/storage';
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
import { usePushNotifications } from '@/services/push-notification';
import { audioService } from '@/services/audio.service';
import { pushNotificationService, usePushNotifications } from '@/services/push-notification';
import { useAudioStreamStore } from '@/stores/app/audio-stream-store';
import { useCoreStore } from '@/stores/app/core-store';
import { useLiveKitStore } from '@/stores/app/livekit-store';
import { useCallsStore } from '@/stores/calls/store';
import { useChatStore } from '@/stores/chat/store';
import { useCheckInStore } from '@/stores/checkIn/store';
import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store';
import useLockscreenStore from '@/stores/lockscreen/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store';

/**
* Tear down every per-session resource on sign-out: SignalR hubs and their heartbeats,
* the LiveKit voice room, audio streams and cached sounds, check-in polling, chat
* timers, and push-notification listeners. Without this, all of them keep running
* against a signed-out session until the process dies.
*/
async function teardownSignedInSession(): Promise<void> {
const signalR = useSignalRStore.getState();
const teardowns: [string, () => Promise<unknown> | unknown][] = [
['SignalR update hub', () => signalR.disconnectUpdateHub()],
['SignalR chat hub', () => signalR.disconnectChatHub()],
['SignalR geolocation hub', () => signalR.disconnectGeolocationHub()],
['LiveKit room', () => useLiveKitStore.getState().disconnectFromRoom()],
['audio stream', () => useAudioStreamStore.getState().cleanup()],
['audio service', () => audioService.cleanup()],
['check-in polling', () => useCheckInStore.getState().stopPolling()],
['chat store', () => useChatStore.getState().reset()],
['push notification listeners', () => pushNotificationService.cleanup()],
];

for (const [label, teardown] of teardowns) {
try {
await teardown();
} catch (error) {
logger.error({
message: `Failed to tear down ${label} on sign-out`,
context: { error: error instanceof Error ? error.message : String(error) },
});
}
}
}

export default function TabLayout() {
const { t } = useTranslation();
const status = useAuthStore((state) => state.status);
Expand Down Expand Up @@ -156,6 +194,23 @@ export default function TabLayout() {

await securityStore.getState().getRights();

// Dispatch shows private command, unit and responder traffic, so a member the department has
// not authorized must not get past initialization. The server is the real boundary — it simply
// never hands an unauthorized user the dispatch channels — but signing them straight back out
// is far clearer than a silently empty app.
if (!isCurrentRun()) {
return;
}
if (securityStore.getState().rights?.CanLoginToDispatchApp === false) {
logger.warn({
message: 'User is not authorized to use the Dispatch app; signing out',
context: { userId },
});
useToastStore.getState().showToast('error', t('login.dispatch_not_authorized'));
await useAuthStore.getState().logout();
return;
}

logger.info({
message: 'Security rights retrieved, fetching feature flags',
context: { platform: Platform.OS },
Expand Down Expand Up @@ -273,7 +328,7 @@ export default function TabLayout() {
// If the init promise is still hanging, clear the guard so a retry is possible
isInitializing.current = false;
}
}, [status]);
}, [status, t, userId]);

const refreshDataFromBackground = useCallback(async () => {
if (status !== 'signedIn' || !hasInitialized.current) return;
Expand Down Expand Up @@ -377,6 +432,12 @@ export default function TabLayout() {
// so the next sign-in is not skipped as "already initializing".
initGeneration.current += 1;
isInitializing.current = false;
// Clear the initialized flag too, or a sign-in later in this process fails
// shouldInitialize and initializeApp never runs for the new session.
hasInitialized.current = false;

// Stop hubs, voice, audio and timers that belong to the ended session
void teardownSignedInSession();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled Promise rejection: Calling teardownSignedInSession() with void discards its Promise, meaning errors thrown before the internal loop (e.g., useSignalRStore.getState() on line 53) violate rule [1]. Attach a .catch() handler or wrap the entire function body in try/catch.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/app/(app)/_layout.tsx:

Line 437:

Unhandled Promise rejection: Calling `teardownSignedInSession()` with `void` discards its Promise, meaning errors thrown before the internal loop (e.g., `useSignalRStore.getState()` on line 53) violate rule [1]. Attach a `.catch()` handler or wrap the entire function body in try/catch.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}

// Update last known status
Expand Down
Loading
Loading