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
59 changes: 59 additions & 0 deletions src/api/calls/__tests__/closeCall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { closeCall } from '../calls';

jest.mock('../../common/client', () => {
const put = jest.fn();
return {
createApiEndpoint: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), put, delete: jest.fn() })),
__mockPut: put,
};
});

jest.mock('../../common/cached-client', () => ({
createCachedApiEndpoint: jest.fn(() => ({ get: jest.fn() })),
}));

jest.mock('@/lib/cache/cache-manager', () => ({
cacheManager: { remove: jest.fn() },
}));

jest.mock('@/lib/logging', () => ({
logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() },
}));

const { __mockPut: mockPut } = jest.requireMock('../../common/client') as { __mockPut: jest.Mock };
const { cacheManager } = jest.requireMock('@/lib/cache/cache-manager') as { cacheManager: { remove: jest.Mock } };

describe('closeCall api', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPut.mockResolvedValue({ data: { Id: '42' } });
});

it('sends Id, Type, Notes and SendNotification when the caller chose whether to notify', async () => {
await closeCall({ callId: '42', type: 3, note: 'Nothing found', sendNotification: true });
expect(mockPut).toHaveBeenCalledWith({ Id: '42', Type: 3, Notes: 'Nothing found', SendNotification: true });

await closeCall({ callId: '42', type: 1, sendNotification: false });
expect(mockPut).toHaveBeenLastCalledWith({ Id: '42', Type: 1, Notes: '', SendNotification: false });
});

it('leaves SendNotification out when not set, so older callers send the same body as before', async () => {
await closeCall({ callId: '42', type: 2, note: 'Cancelled by caller' });

expect(mockPut).toHaveBeenCalledWith({ Id: '42', Type: 2, Notes: 'Cancelled by caller' });
expect(mockPut.mock.calls[0][0]).not.toHaveProperty('SendNotification');
});

it('invalidates the active calls cache and returns the body', async () => {
await expect(closeCall({ callId: '42', type: 1 })).resolves.toEqual({ Id: '42' });
expect(cacheManager.remove).toHaveBeenCalledWith('/Calls/GetActiveCalls');
});

it('propagates a refusal to the caller', async () => {
const refusal = Object.assign(new Error('Request failed with status code 400'), { isAxiosError: true, response: { status: 400, data: 'This call has an active incident command.' } });
mockPut.mockRejectedValueOnce(refusal);

await expect(closeCall({ callId: '42', type: 1, sendNotification: true })).rejects.toBe(refusal);
expect(cacheManager.remove).not.toHaveBeenCalled();
});
});
6 changes: 6 additions & 0 deletions src/api/calls/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export interface CloseCallRequest {
callId: string;
type: number;
note?: string;
/**
* Alert everyone attached to the call (dispatched personnel, groups, roles, units and the incident
* command team) that it is closed. Omitted from the request when not set, leaving the server default.
*/
sendNotification?: boolean;
}

/**
Expand Down Expand Up @@ -194,6 +199,7 @@ export const closeCall = async (callData: CloseCallRequest) => {
Id: callData.callId,
Type: callData.type,
Notes: callData.note || '',
...(callData.sendNotification !== undefined ? { SendNotification: callData.sendNotification } : {}),
};

const response = await closeCallApi.put<SaveCallResult>(data);
Expand Down
3 changes: 3 additions & 0 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,9 @@ export default function TabLayout() {

<Tabs.Screen name="settings" options={settingsOptions} />

{/* Opened from Settings; without a declaration expo-router adds it to the tab bar as "account-security". */}
<Tabs.Screen name="account-security" options={{ href: null, title: t('mfa.account.title') }} />

<Tabs.Screen name="chat" options={chatOptions} />

<Tabs.Screen name="chatbot" options={chatbotOptions} />
Expand Down
88 changes: 88 additions & 0 deletions src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ jest.mock('@/components/ui/textarea', () => ({
},
}));

jest.mock('@/components/ui/switch', () => ({
Switch: ({ value, onValueChange, isDisabled, testID }: any) => {
const { Switch: RNSwitch } = require('react-native');
return <RNSwitch value={value} onValueChange={onValueChange} disabled={isDisabled} testID={testID} />;
},
}));

const mockRouter = {
back: jest.fn(),
replace: jest.fn(),
Expand Down Expand Up @@ -282,6 +289,7 @@ describe('CloseCallBottomSheet', () => {
callId: 'test-call-1',
type: 1,
note: 'Call resolved successfully',
sendNotification: true,
});
expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success');
expect(mockFetchCalls).toHaveBeenCalled();
Expand Down Expand Up @@ -309,6 +317,7 @@ describe('CloseCallBottomSheet', () => {
callId: 'test-call-1',
type: 2,
note: '',
sendNotification: true,
});
expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success');
expect(mockFetchCalls).toHaveBeenCalled();
Expand Down Expand Up @@ -338,6 +347,84 @@ describe('CloseCallBottomSheet', () => {
expect(mockRouter.replace).not.toHaveBeenCalled();
});

it('shows the server reason when the close is refused (call has an active incident command)', async () => {
const reason = 'This call has an active incident command. Close the incident command first, then close the call.';
mockCloseCall.mockRejectedValue(Object.assign(new Error('Request failed with status code 400'), { isAxiosError: true, response: { status: 400, data: reason } }));

render(<CloseCallBottomSheet isOpen={true} onClose={jest.fn()} callId="test-call-1" />);
selectCloseCallType('1');
fireEvent.press(screen.getAllByText('call_detail.close_call')[1]);

await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('error', reason);
});
expect(mockShowToast).not.toHaveBeenCalledWith('error', 'call_detail.close_call_error');
expect(mockFetchCalls).not.toHaveBeenCalled();
expect(mockRouter.replace).not.toHaveBeenCalled();
});

it('notifies everyone on the call by default and lets the switch turn it off', async () => {
mockCloseCall.mockResolvedValue(undefined);
mockFetchCalls.mockResolvedValue(undefined);

render(<CloseCallBottomSheet isOpen={true} onClose={jest.fn()} callId="test-call-1" />);

expect(screen.getByText('call_detail.close_call_notify')).toBeTruthy();
expect(screen.getByText('call_detail.close_call_notify_hint')).toBeTruthy();
const notifySwitch = screen.getByTestId('close-call-notify-switch');
expect(notifySwitch.props.value).toBe(true);

fireEvent(notifySwitch, 'onValueChange', false);
selectCloseCallType('7');
fireEvent.press(screen.getAllByText('call_detail.close_call')[1]);

await waitFor(() => {
expect(mockCloseCall).toHaveBeenCalledWith({ callId: 'test-call-1', type: 7, note: '', sendNotification: false });
});
});

it('locks the notify switch while the close is in flight', async () => {
let resolveClose: () => void = () => {};
mockCloseCall.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveClose = resolve;
})
);
mockFetchCalls.mockResolvedValue(undefined);

render(<CloseCallBottomSheet isOpen={true} onClose={jest.fn()} callId="test-call-1" />);

expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBeFalsy();

selectCloseCallType('1');
fireEvent.press(screen.getAllByText('call_detail.close_call')[1]);

await waitFor(() => {

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 assertion or async failures in the awaited waitFor call at src/components/calls/__tests__/close-call-bottom-sheet.test.tsx:410 provide no contextual error information. Wrap the call in try/catch and rethrow an Error with context, preserving the original failure as its cause.

Kody rule violation: Handle async operations with proper error handling

try {
  await waitFor(() => {
    expect(mockCloseCall).toHaveBeenCalled();
    expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBe(true);
  });
} catch (error) {
  throw new Error('Failed while waiting for notify switch to disable', { cause: error });
}
Prompt for LLM

File src/components/calls/__tests__/close-call-bottom-sheet.test.tsx:

Line 403:

Unhandled assertion or async failures in the awaited `waitFor` call at `src/components/calls/__tests__/close-call-bottom-sheet.test.tsx:410` provide no contextual error information. Wrap the call in `try/catch` and rethrow an `Error` with context, preserving the original failure as its cause.

Suggested Code:

    try {
      await waitFor(() => {
        expect(mockCloseCall).toHaveBeenCalled();
        expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBe(true);
      });
    } catch (error) {
      throw new Error('Failed while waiting for notify switch to disable', { cause: error });
    }

Talk to Kody by mentioning @kody

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

​

​

expect(mockCloseCall).toHaveBeenCalled();
expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBe(true);
});

resolveClose();

await waitFor(() => {
expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBeFalsy();
});
});

it('turns the notify switch back on when the sheet is cancelled', () => {
const mockOnClose = jest.fn();
render(<CloseCallBottomSheet isOpen={true} onClose={mockOnClose} callId="test-call-1" />);

fireEvent(screen.getByTestId('close-call-notify-switch'), 'onValueChange', false);
expect(screen.getByTestId('close-call-notify-switch').props.value).toBe(false);

fireEvent.press(screen.getByText('common.cancel'));

expect(mockOnClose).toHaveBeenCalled();
expect(screen.getByTestId('close-call-notify-switch').props.value).toBe(true);
});

it.each([
{ type: '1', expected: 1 },
{ type: '2', expected: 2 },
Expand Down Expand Up @@ -365,6 +452,7 @@ describe('CloseCallBottomSheet', () => {
callId: 'test-call-1',
type: expected,
note: '',
sendNotification: true,
});
expect(mockFetchCalls).toHaveBeenCalled();
});
Expand Down
21 changes: 18 additions & 3 deletions src/components/calls/close-call-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { FormControl, FormControlLabel, FormControlLabelText } from '@/component
import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
import { getCallCloseErrorMessage } from '@/lib/call-close';
import { logger } from '@/lib/logging';
import { useCallDetailStore } from '@/stores/calls/detail-store';
import { useCallsStore } from '@/stores/calls/store';
Expand All @@ -36,6 +38,8 @@ export const CloseCallBottomSheet: React.FC<CloseCallBottomSheetProps> = ({ isOp
const fetchCalls = useCallsStore((state) => state.fetchCalls);
const [closeCallType, setCloseCallType] = useState('');
const [closeCallNote, setCloseCallNote] = useState('');
// Alert everyone on the call (personnel, groups, roles, units, command team) that it closed — on by default.
const [sendNotification, setSendNotification] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);

// Track when close call bottom sheet is opened/rendered
Expand All @@ -51,6 +55,7 @@ export const CloseCallBottomSheet: React.FC<CloseCallBottomSheetProps> = ({ isOp
const handleClose = React.useCallback(() => {
setCloseCallType('');
setCloseCallNote('');
setSendNotification(true);
onClose();
}, [onClose]);

Expand All @@ -67,6 +72,7 @@ export const CloseCallBottomSheet: React.FC<CloseCallBottomSheetProps> = ({ isOp
callId,
type: parseInt(closeCallType),
note: closeCallNote,
sendNotification,
});

// Show success toast
Expand All @@ -80,12 +86,12 @@ export const CloseCallBottomSheet: React.FC<CloseCallBottomSheetProps> = ({ isOp
await fetchCalls();
} catch (error) {
logger.error({ message: 'Error closing call', context: { error, callId } });
// Show error toast
showToast('error', t('call_detail.close_call_error'));
// Show the server's reason when it gave one (e.g. the call still has an active incident command)
showToast('error', getCallCloseErrorMessage(error) ?? t('call_detail.close_call_error'));
} finally {
setIsSubmitting(false);
}
}, [closeCallType, showToast, t, callId, closeCallNote, handleClose, fetchCalls, router, closeCall]);
}, [closeCallType, showToast, t, callId, closeCallNote, sendNotification, handleClose, fetchCalls, router, closeCall]);

const isButtonDisabled = isLoading || isSubmitting;

Expand Down Expand Up @@ -143,6 +149,15 @@ export const CloseCallBottomSheet: React.FC<CloseCallBottomSheetProps> = ({ isOp
</Textarea>
</VStack>

<HStack space="sm" className="items-center justify-between">
<VStack className="flex-1">
<Text className="font-medium">{t('call_detail.close_call_notify')}</Text>
<Text className="text-xs text-gray-500 dark:text-gray-400">{t('call_detail.close_call_notify_hint')}</Text>
</VStack>
{/* Locked while submitting: the choice is already on its way to the server. */}
<Switch value={sendNotification} onValueChange={setSendNotification} isDisabled={isSubmitting} accessibilityLabel={t('call_detail.close_call_notify')} testID="close-call-notify-switch" />
</HStack>

<HStack space="sm" className="mt-4 justify-between">
<Button variant="outline" className="flex-1" onPress={handleClose} isDisabled={isButtonDisabled}>
<ButtonText>{t('common.cancel')}</ButtonText>
Expand Down
Loading
Loading