diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx new file mode 100644 index 0000000..cbb164d --- /dev/null +++ b/src/app/(app)/__tests__/protocols.test.tsx @@ -0,0 +1,389 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react-native'; +import React from 'react'; + +import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; + +import Protocols from '../protocols'; + +// Mock dependencies +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('@/components/common/loading', () => ({ + Loading: () => { + const { Text } = require('react-native'); + return Loading; + }, +})); + +jest.mock('@/components/common/zero-state', () => ({ + __esModule: true, + default: ({ heading, description }: { heading: string; description: string }) => { + const { Text } = require('react-native'); + return {`ZeroState: ${heading}`}; + }, +})); + +jest.mock('@/components/protocols/protocol-card', () => ({ + ProtocolCard: ({ protocol, onPress }: { protocol: any; onPress: (id: string) => void }) => { + const { Pressable, Text } = require('react-native'); + return ( + onPress(protocol.Id)}> + {protocol.Name} + + ); + }, +})); + +jest.mock('@/components/protocols/protocol-details-sheet', () => ({ + ProtocolDetailsSheet: () => { + const { Text } = require('react-native'); + return ProtocolDetailsSheet; + }, +})); + +// Mock the protocols store +const mockProtocolsStore = { + protocols: [], + searchQuery: '', + setSearchQuery: jest.fn(), + selectProtocol: jest.fn(), + isLoading: false, + fetchProtocols: jest.fn(), +}; + +jest.mock('@/stores/protocols/store', () => ({ + useProtocolsStore: () => mockProtocolsStore, +})); + +// Mock protocols test data +const mockProtocols: CallProtocolsResultData[] = [ + { + Id: '1', + DepartmentId: 'dept1', + Name: 'Fire Emergency Response', + Code: 'FIRE001', + Description: 'Standard fire emergency response protocol', + ProtocolText: '

Fire emergency response protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, + { + Id: '2', + DepartmentId: 'dept1', + Name: 'Medical Emergency', + Code: 'MED001', + Description: 'Medical emergency response protocol', + ProtocolText: '

Medical emergency response protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, + { + Id: '3', + DepartmentId: 'dept1', + Name: 'Hazmat Response', + Code: 'HAZ001', + Description: 'Hazardous material response protocol', + ProtocolText: '

Hazmat response protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, + { + Id: '', // Empty ID to test the keyExtractor fix + DepartmentId: 'dept1', + Name: 'Protocol with Empty ID', + Code: 'EMPTY001', + Description: 'Protocol with empty ID for testing', + ProtocolText: '

Protocol with empty ID content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, +]; + +describe('Protocols Page', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock store to default state + Object.assign(mockProtocolsStore, { + protocols: [], + searchQuery: '', + setSearchQuery: jest.fn(), + selectProtocol: jest.fn(), + isLoading: false, + fetchProtocols: jest.fn(), + }); + }); + + it('should render loading state during initial fetch', () => { + Object.assign(mockProtocolsStore, { + isLoading: true, + protocols: [], + }); + + render(); + + expect(screen.getByText('Loading')).toBeTruthy(); + }); + + it('should render protocols list when data is loaded', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + isLoading: false, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-2')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-3')).toBeTruthy(); + }); + + expect(mockProtocolsStore.fetchProtocols).toHaveBeenCalledTimes(1); + }); + + it('should handle protocols with empty IDs using keyExtractor fallback', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + isLoading: false, + }); + + render(); + + await waitFor(() => { + // The protocol with empty ID should render with fallback key + expect(screen.getByText('Protocol with Empty ID')).toBeTruthy(); + }); + }); + + it('should render zero state when no protocols are available', () => { + Object.assign(mockProtocolsStore, { + protocols: [], + isLoading: false, + }); + + render(); + + expect(screen.getByText('ZeroState: protocols.empty')).toBeTruthy(); + }); + + it('should filter protocols based on search query by name', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'fire', + isLoading: false, + }); + + render(); + + // Only Fire Emergency Response should be visible in filtered results + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + expect(screen.queryByTestId('protocol-card-2')).toBeFalsy(); + expect(screen.queryByTestId('protocol-card-3')).toBeFalsy(); + }); + }); + + it('should filter protocols based on search query by code', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'MED001', + isLoading: false, + }); + + render(); + + // Only Medical Emergency should be visible in filtered results + await waitFor(() => { + expect(screen.queryByTestId('protocol-card-1')).toBeFalsy(); + expect(screen.getByTestId('protocol-card-2')).toBeTruthy(); + expect(screen.queryByTestId('protocol-card-3')).toBeFalsy(); + }); + }); + + it('should filter protocols based on search query by description', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'hazardous', + isLoading: false, + }); + + render(); + + // Only Hazmat Response should be visible in filtered results + await waitFor(() => { + expect(screen.queryByTestId('protocol-card-1')).toBeFalsy(); + expect(screen.queryByTestId('protocol-card-2')).toBeFalsy(); + expect(screen.getByTestId('protocol-card-3')).toBeTruthy(); + }); + }); + + it('should show zero state when search returns no results', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'nonexistent', + isLoading: false, + }); + + render(); + + expect(screen.getByText('ZeroState: protocols.empty')).toBeTruthy(); + }); + + it('should handle search input changes', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: '', + isLoading: false, + }); + + render(); + + const searchInput = screen.getByPlaceholderText('protocols.search'); + fireEvent.changeText(searchInput, 'fire'); + + expect(mockProtocolsStore.setSearchQuery).toHaveBeenCalledWith('fire'); + }); + + it('should clear search query when X button is pressed', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'fire', + isLoading: false, + }); + + render(); + + const searchInput = screen.getByDisplayValue('fire'); + expect(searchInput).toBeTruthy(); + + // Test that the clear functionality would work + fireEvent.changeText(searchInput, ''); + expect(mockProtocolsStore.setSearchQuery).toHaveBeenCalledWith(''); + }); + + it('should handle protocol selection', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: '', + isLoading: false, + }); + + render(); + + const protocolCard = screen.getByTestId('protocol-card-1'); + fireEvent.press(protocolCard); + + expect(mockProtocolsStore.selectProtocol).toHaveBeenCalledWith('1'); + }); + + it('should handle pull-to-refresh', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + isLoading: false, + }); + + render(); + + // The FlatList should be rendered with RefreshControl + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + }); + + expect(mockProtocolsStore.fetchProtocols).toHaveBeenCalledTimes(1); + }); + + it('should render protocol details sheet', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + isLoading: false, + }); + + render(); + + expect(screen.getByText('ProtocolDetailsSheet')).toBeTruthy(); + }); + + it('should handle case-insensitive search', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: 'FIRE', + isLoading: false, + }); + + render(); + + // Should match "Fire Emergency Response" despite different case + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + }); + }); + + it('should handle empty search query by showing all protocols', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: '', + isLoading: false, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-2')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-3')).toBeTruthy(); + }); + }); + + it('should handle whitespace-only search query by showing all protocols', async () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + searchQuery: ' ', + isLoading: false, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('protocol-card-1')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-2')).toBeTruthy(); + expect(screen.getByTestId('protocol-card-3')).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx new file mode 100644 index 0000000..7d9cda9 --- /dev/null +++ b/src/app/(app)/__tests__/signalr-lifecycle.test.tsx @@ -0,0 +1,164 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { AppStateStatus } from 'react-native'; + +import { useSignalRStore } from '@/stores/signalr/signalr-store'; + +// Mock the SignalR store +jest.mock('@/stores/signalr/signalr-store'); + +const mockUseSignalRStore = useSignalRStore as jest.MockedFunction; + +// Create a custom hook to test the SignalR lifecycle logic +function useSignalRLifecycle(isActive: boolean, appState: AppStateStatus, isSignedIn: boolean, hasInitialized: boolean) { + const signalRStore = useSignalRStore(); + + React.useEffect(() => { + // Handle app going to background + if (!isActive && (appState === 'background' || appState === 'inactive') && hasInitialized && isSignedIn) { + signalRStore.disconnectUpdateHub(); + signalRStore.disconnectGeolocationHub(); + } + }, [isActive, appState, hasInitialized, isSignedIn, signalRStore]); + + React.useEffect(() => { + // Handle app resuming from background + if (isActive && appState === 'active' && hasInitialized && isSignedIn) { + signalRStore.connectUpdateHub(); + signalRStore.connectGeolocationHub(); + } + }, [isActive, appState, hasInitialized, isSignedIn, signalRStore]); + + return signalRStore; +} + +describe('SignalR Lifecycle Management', () => { + const mockConnectUpdateHub = jest.fn(); + const mockDisconnectUpdateHub = jest.fn(); + const mockConnectGeolocationHub = jest.fn(); + const mockDisconnectGeolocationHub = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock SignalR store + mockUseSignalRStore.mockReturnValue({ + connectUpdateHub: mockConnectUpdateHub, + disconnectUpdateHub: mockDisconnectUpdateHub, + connectGeolocationHub: mockConnectGeolocationHub, + disconnectGeolocationHub: mockDisconnectGeolocationHub, + isUpdateHubConnected: false, + isGeolocationHubConnected: false, + } as any); + }); + + it('should disconnect SignalR when app goes to background', async () => { + const { rerender } = renderHook( + ({ isActive, appState, isSignedIn, hasInitialized }) => + useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized), + { + initialProps: { + isActive: true, + appState: 'active' as AppStateStatus, + isSignedIn: true, + hasInitialized: true, + }, + } + ); + + // Simulate app going to background + rerender({ + isActive: false, + appState: 'background' as AppStateStatus, + isSignedIn: true, + hasInitialized: true, + }); + + await waitFor(() => { + expect(mockDisconnectUpdateHub).toHaveBeenCalled(); + expect(mockDisconnectGeolocationHub).toHaveBeenCalled(); + }); + }); + + it('should reconnect SignalR when app becomes active again', async () => { + const { rerender } = renderHook( + ({ isActive, appState, isSignedIn, hasInitialized }) => + useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized), + { + initialProps: { + isActive: false, + appState: 'background' as AppStateStatus, + isSignedIn: true, + hasInitialized: true, + }, + } + ); + + // Simulate app becoming active + rerender({ + isActive: true, + appState: 'active' as AppStateStatus, + isSignedIn: true, + hasInitialized: true, + }); + + await waitFor(() => { + expect(mockConnectUpdateHub).toHaveBeenCalled(); + expect(mockConnectGeolocationHub).toHaveBeenCalled(); + }); + }); + + it('should not manage SignalR connections when user is not signed in', async () => { + const { rerender } = renderHook( + ({ isActive, appState, isSignedIn, hasInitialized }) => + useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized), + { + initialProps: { + isActive: true, + appState: 'active' as AppStateStatus, + isSignedIn: false, + hasInitialized: true, + }, + } + ); + + // Simulate app going to background + rerender({ + isActive: false, + appState: 'background' as AppStateStatus, + isSignedIn: false, + hasInitialized: true, + }); + + // Should not call SignalR methods when user is not signed in + expect(mockDisconnectUpdateHub).not.toHaveBeenCalled(); + expect(mockDisconnectGeolocationHub).not.toHaveBeenCalled(); + }); + + it('should not manage SignalR connections when app is not initialized', async () => { + const { rerender } = renderHook( + ({ isActive, appState, isSignedIn, hasInitialized }) => + useSignalRLifecycle(isActive, appState, isSignedIn, hasInitialized), + { + initialProps: { + isActive: true, + appState: 'active' as AppStateStatus, + isSignedIn: true, + hasInitialized: false, + }, + } + ); + + // Simulate app going to background + rerender({ + isActive: false, + appState: 'background' as AppStateStatus, + isSignedIn: true, + hasInitialized: false, + }); + + // Should not call SignalR methods when app is not initialized + expect(mockDisconnectUpdateHub).not.toHaveBeenCalled(); + expect(mockDisconnectGeolocationHub).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 7fa4d10..58a19e9 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -16,6 +16,7 @@ import { Drawer, DrawerBackdrop, DrawerBody, DrawerContent, DrawerFooter, Drawer import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; +import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { useAuthStore } from '@/lib/auth'; import { logger } from '@/lib/logging'; import { useIsFirstTime } from '@/lib/storage'; @@ -26,6 +27,7 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; export default function TabLayout() { const { t } = useTranslation(); @@ -96,10 +98,14 @@ export default function TabLayout() { await securityStore.getState().getRights(); await useCoreStore.getState().fetchConfig(); + await useSignalRStore.getState().connectUpdateHub(); + await useSignalRStore.getState().connectGeolocationHub(); + + hasInitialized.current = true; + // Initialize Bluetooth service await bluetoothAudioService.initialize(); - hasInitialized.current = true; logger.info({ message: 'App initialization completed successfully', }); @@ -123,6 +129,7 @@ export default function TabLayout() { }); try { + // Refresh data await Promise.all([useCoreStore.getState().fetchConfig(), useCallsStore.getState().fetchCalls(), useRolesStore.getState().fetchRoles()]); } catch (error) { logger.error({ @@ -132,6 +139,12 @@ export default function TabLayout() { } }, [status]); + // Handle SignalR lifecycle management + useSignalRLifecycle({ + isSignedIn: status === 'signedIn', + hasInitialized: hasInitialized.current, + }); + // Handle splash screen hiding useEffect(() => { if (status !== 'idle' && !hasHiddenSplash.current) { diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 7e0435e..06ac62e 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -105,7 +105,7 @@ export default function Map() { }; fetchMapDataAndMarkers(); - }, [setMapPins]); + }, []); useEffect(() => { Animated.loop( diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index 9bcbae7..1ba3e1e 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -55,7 +55,7 @@ export default function Protocols() { ) : filteredProtocols.length > 0 ? ( item.Id} + keyExtractor={(item, index) => item.Id || `protocol-${index}`} renderItem={({ item }) => } showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx index 562e022..b53f0c2 100644 --- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx +++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react-native'; +import { render, act } from '@testing-library/react-native'; import React from 'react'; import { beforeEach, describe, expect, it, jest } from '@jest/globals'; @@ -334,8 +334,7 @@ describe('LiveKitBottomSheet', () => { it('should handle view transitions', () => { const fetchVoiceSettings = jest.fn(); - // Start with room selection - const { rerender } = render(); + // Start with room selection view mockUseLiveKitStore.mockReturnValue({ ...defaultLiveKitState, isBottomSheetVisible: true, @@ -343,10 +342,11 @@ describe('LiveKitBottomSheet', () => { fetchVoiceSettings, }); - rerender(); + const { rerender } = render(); expect(fetchVoiceSettings).toHaveBeenCalled(); - // Connect to room + // Connect to room - fetchVoiceSettings should not be called again since we're now in connected view + fetchVoiceSettings.mockClear(); mockUseLiveKitStore.mockReturnValue({ ...defaultLiveKitState, isBottomSheetVisible: true, @@ -357,14 +357,13 @@ describe('LiveKitBottomSheet', () => { }); rerender(); - expect(fetchVoiceSettings).toHaveBeenCalled(); + expect(fetchVoiceSettings).not.toHaveBeenCalled(); // Should not be called in connected view }); it('should handle microphone state changes', () => { const fetchVoiceSettings = jest.fn(); - // Start with muted microphone - const { rerender } = render(); + // Start with muted microphone in connected state mockUseLiveKitStore.mockReturnValue({ ...defaultLiveKitState, isBottomSheetVisible: true, @@ -374,7 +373,9 @@ describe('LiveKitBottomSheet', () => { fetchVoiceSettings, }); - rerender(); + const { rerender } = render(); + // Clear the initial call that happens during render before the view switches + fetchVoiceSettings.mockClear(); // Enable microphone const enabledMockRoom = { @@ -394,7 +395,7 @@ describe('LiveKitBottomSheet', () => { }); rerender(); - expect(fetchVoiceSettings).toHaveBeenCalled(); + expect(fetchVoiceSettings).not.toHaveBeenCalled(); // Should not be called when just changing microphone state }); }); }); \ No newline at end of file diff --git a/src/components/protocols/__tests__/protocol-card.test.tsx b/src/components/protocols/__tests__/protocol-card.test.tsx new file mode 100644 index 0000000..3fda919 --- /dev/null +++ b/src/components/protocols/__tests__/protocol-card.test.tsx @@ -0,0 +1,277 @@ +import { render, screen, fireEvent } from '@testing-library/react-native'; +import React from 'react'; + +import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; + +import { ProtocolCard } from '../protocol-card'; + +// Mock dependencies +jest.mock('@/lib/utils', () => ({ + formatDateForDisplay: jest.fn((date) => date ? '2023-01-01 12:00 UTC' : ''), + parseDateISOString: jest.fn((dateString) => dateString ? new Date(dateString) : null), + stripHtmlTags: jest.fn((html) => html ? html.replace(/<[^>]*>/g, '') : ''), +})); + +describe('ProtocolCard', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + mockOnPress.mockClear(); + }); + + const baseProtocol: CallProtocolsResultData = { + Id: '1', + DepartmentId: 'dept1', + Name: 'Fire Emergency Response', + Code: 'FIRE001', + Description: 'Standard fire emergency response protocol', + ProtocolText: '

Fire emergency response protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }; + + const protocolWithoutOptionalFields: CallProtocolsResultData = { + Id: '2', + DepartmentId: 'dept1', + Name: 'Basic Protocol', + Code: '', + Description: '', + ProtocolText: '', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '', + UpdatedByUserId: '', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }; + + const protocolWithHtmlDescription: CallProtocolsResultData = { + Id: '3', + DepartmentId: 'dept1', + Name: 'Protocol with HTML', + Code: 'HTML001', + Description: '

This is a description with HTML tags

', + ProtocolText: '

Protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }; + + describe('Basic Rendering', () => { + it('should render protocol card with all fields', () => { + render(); + + expect(screen.getByText('Fire Emergency Response')).toBeTruthy(); + expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy(); + expect(screen.getByText('FIRE001')).toBeTruthy(); + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + + it('should render protocol card without optional fields', () => { + render(); + + expect(screen.getByText('Basic Protocol')).toBeTruthy(); + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + // Code badge should not be rendered when code is empty - we can't test for empty string as it's always rendered + expect(screen.queryByText('FIRE001')).toBeFalsy(); + }); + + it('should handle protocol with HTML in description', () => { + render(); + + expect(screen.getByText('Protocol with HTML')).toBeTruthy(); + expect(screen.getByText('HTML001')).toBeTruthy(); + // Description should be stripped of HTML tags + expect(screen.getByText('This is a description with HTML tags')).toBeTruthy(); + }); + }); + + describe('Interactions', () => { + it('should call onPress with protocol ID when card is pressed', () => { + render(); + + const card = screen.getByText('Fire Emergency Response'); + fireEvent.press(card); + + expect(mockOnPress).toHaveBeenCalledWith('1'); + }); + + it('should call onPress with correct ID for different protocols', () => { + render(); + + const card = screen.getByText('Basic Protocol'); + fireEvent.press(card); + + expect(mockOnPress).toHaveBeenCalledWith('2'); + }); + + it('should handle multiple press events', () => { + render(); + + const card = screen.getByText('Fire Emergency Response'); + fireEvent.press(card); + fireEvent.press(card); + + expect(mockOnPress).toHaveBeenCalledTimes(2); + expect(mockOnPress).toHaveBeenCalledWith('1'); + }); + }); + + describe('Date Display', () => { + it('should display UpdatedOn date when available', () => { + render(); + + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + + it('should fall back to CreatedOn when UpdatedOn is not available', () => { + render(); + + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + }); + + describe('Code Badge Display', () => { + it('should display code badge when code is provided', () => { + render(); + + expect(screen.getByText('FIRE001')).toBeTruthy(); + }); + + it('should not display code badge when code is empty', () => { + render(); + + // The code badge section should not exist - we can't test for empty string as it's always rendered + expect(screen.queryByText('FIRE001')).toBeFalsy(); + }); + + it('should not display code badge when code is null', () => { + const protocolWithNullCode = { ...baseProtocol, Code: null as any }; + render(); + + // The code badge section should not exist + expect(screen.queryByText('null')).toBeFalsy(); + }); + }); + + describe('Description Display', () => { + it('should display description when provided', () => { + render(); + + expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy(); + }); + + it('should handle empty description', () => { + render(); + + expect(screen.getByText('Basic Protocol')).toBeTruthy(); + // Empty description should render empty text + expect(screen.getByText('')).toBeTruthy(); + }); + + it('should strip HTML tags from description', () => { + render(); + + expect(screen.getByText('This is a description with HTML tags')).toBeTruthy(); + // Should not contain HTML tags + expect(screen.queryByText('

This is a description with HTML tags

')).toBeFalsy(); + }); + + it('should handle null description', () => { + const protocolWithNullDescription = { ...baseProtocol, Description: null as any }; + render(); + + expect(screen.getByText('Fire Emergency Response')).toBeTruthy(); + expect(screen.getByText('')).toBeTruthy(); + }); + }); + + describe('Text Truncation', () => { + it('should limit description to 2 lines', () => { + const protocolWithLongDescription = { + ...baseProtocol, + Description: 'This is a very long description that should be truncated when it exceeds two lines of text in the protocol card component', + }; + + render(); + + expect(screen.getByText('This is a very long description that should be truncated when it exceeds two lines of text in the protocol card component')).toBeTruthy(); + }); + }); + + describe('Edge Cases', () => { + it('should handle protocol with empty ID', () => { + const protocolWithEmptyId = { ...baseProtocol, Id: '' }; + render(); + + const card = screen.getByText('Fire Emergency Response'); + fireEvent.press(card); + + expect(mockOnPress).toHaveBeenCalledWith(''); + }); + + it('should handle protocol with special characters in name', () => { + const protocolWithSpecialChars = { + ...baseProtocol, + Name: 'Protocol & Emergency ', + }; + + render(); + + expect(screen.getByText('Protocol & Emergency ')).toBeTruthy(); + }); + + it('should handle protocol with very long name', () => { + const protocolWithLongName = { + ...baseProtocol, + Name: 'Very Long Protocol Name That Might Overflow The Card Layout And Should Be Handled Gracefully', + }; + + render(); + + expect(screen.getByText('Very Long Protocol Name That Might Overflow The Card Layout And Should Be Handled Gracefully')).toBeTruthy(); + }); + + it('should handle protocol with very long code', () => { + const protocolWithLongCode = { + ...baseProtocol, + Code: 'VERY_LONG_CODE_THAT_MIGHT_OVERFLOW_THE_BADGE', + }; + + render(); + + expect(screen.getByText('VERY_LONG_CODE_THAT_MIGHT_OVERFLOW_THE_BADGE')).toBeTruthy(); + }); + }); + + describe('Accessibility', () => { + it('should be accessible for screen readers', () => { + render(); + + const card = screen.getByText('Fire Emergency Response'); + expect(card).toBeTruthy(); + // The card should be pressable + fireEvent.press(card); + expect(mockOnPress).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx new file mode 100644 index 0000000..25c01a3 --- /dev/null +++ b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx @@ -0,0 +1,381 @@ +import { render, screen, fireEvent } from '@testing-library/react-native'; +import React from 'react'; + +import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; + +import { ProtocolDetailsSheet } from '../protocol-details-sheet'; + +// Mock dependencies +jest.mock('@/lib/utils', () => ({ + formatDateForDisplay: jest.fn((date) => date ? '2023-01-01 12:00 UTC' : ''), + parseDateISOString: jest.fn((dateString) => dateString ? new Date(dateString) : null), + stripHtmlTags: jest.fn((html) => html ? html.replace(/<[^>]*>/g, '') : ''), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), +})); + +jest.mock('react-native-webview', () => ({ + __esModule: true, + default: ({ source }: { source: any }) => { + const { View, Text } = require('react-native'); + return ( + + {source.html} + + ); + }, +})); + +// Mock the protocols store +const mockProtocolsStore = { + protocols: [], + selectedProtocolId: null, + isDetailsOpen: false, + closeDetails: jest.fn(), +}; + +jest.mock('@/stores/protocols/store', () => ({ + useProtocolsStore: () => mockProtocolsStore, +})); + +// Mock the UI components +jest.mock('@/components/ui/actionsheet', () => ({ + Actionsheet: ({ children }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, + ActionsheetBackdrop: ({ children }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, + ActionsheetContent: ({ children }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, + ActionsheetDragIndicator: () => { + const { View } = require('react-native'); + return ; + }, + ActionsheetDragIndicatorWrapper: ({ children }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +// Mock protocols test data +const mockProtocols: CallProtocolsResultData[] = [ + { + Id: '1', + DepartmentId: 'dept1', + Name: 'Fire Emergency Response', + Code: 'FIRE001', + Description: 'Standard fire emergency response protocol', + ProtocolText: '

Fire emergency response protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '2023-01-02T00:00:00Z', + UpdatedByUserId: 'user1', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, + { + Id: '2', + DepartmentId: 'dept1', + Name: 'Basic Protocol', + Code: '', + Description: '', + ProtocolText: '

Basic protocol content

', + CreatedOn: '2023-01-01T00:00:00Z', + CreatedByUserId: 'user1', + IsDisabled: false, + UpdatedOn: '', + UpdatedByUserId: '', + MinimumWeight: 0, + State: 1, + Triggers: [], + Attachments: [], + Questions: [], + }, +]; + +describe('ProtocolDetailsSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock store to default state + Object.assign(mockProtocolsStore, { + protocols: [], + selectedProtocolId: null, + isDetailsOpen: false, + closeDetails: jest.fn(), + }); + }); + + describe('Sheet Visibility', () => { + it('should render when isDetailsOpen is true and selectedProtocol exists', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + + render(); + + expect(screen.getByText('Fire Emergency Response')).toBeTruthy(); + }); + }); + + describe('Protocol Information Display', () => { + beforeEach(() => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + }); + + it('should display protocol name in header', () => { + render(); + + expect(screen.getByText('Fire Emergency Response')).toBeTruthy(); + }); + + it('should display protocol code when available', () => { + render(); + + expect(screen.getByText('FIRE001')).toBeTruthy(); + }); + + it('should display protocol description when available', () => { + render(); + + expect(screen.getByText('Standard fire emergency response protocol')).toBeTruthy(); + }); + + it('should display formatted date', () => { + render(); + + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + + it('should display protocol content in WebView', () => { + render(); + + const webview = screen.getByTestId('webview'); + expect(webview).toBeTruthy(); + + const webviewContent = screen.getByTestId('webview-content'); + expect(webviewContent).toBeTruthy(); + expect(webviewContent.props.children).toContain('

Fire emergency response protocol content

'); + }); + }); + + describe('Protocol without Optional Fields', () => { + beforeEach(() => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '2', + isDetailsOpen: true, + }); + }); + + it('should not display code section when code is empty', () => { + render(); + + expect(screen.getByText('Basic Protocol')).toBeTruthy(); + expect(screen.queryByText('FIRE001')).toBeFalsy(); + }); + + it('should not display description section when description is empty', () => { + render(); + + expect(screen.getByText('Basic Protocol')).toBeTruthy(); + expect(screen.queryByText('Standard fire emergency response protocol')).toBeFalsy(); + }); + + it('should still display WebView with protocol content', () => { + render(); + + const webview = screen.getByTestId('webview'); + expect(webview).toBeTruthy(); + + const webviewContent = screen.getByTestId('webview-content'); + expect(webviewContent.props.children).toContain('

Basic protocol content

'); + }); + }); + + describe('Close Functionality', () => { + beforeEach(() => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + }); + + it('should have close button in header', () => { + render(); + + const closeButton = screen.getByTestId('close-button'); + expect(closeButton).toBeTruthy(); + }); + + it('should call closeDetails when close button is pressed', () => { + render(); + + const closeButton = screen.getByTestId('close-button'); + fireEvent.press(closeButton); + + expect(mockProtocolsStore.closeDetails).toHaveBeenCalledTimes(1); + }); + }); + + describe('WebView Content', () => { + beforeEach(() => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + }); + + it('should render WebView with proper HTML structure', () => { + render(); + + const webviewContent = screen.getByTestId('webview-content'); + const htmlContent = webviewContent.props.children; + + expect(htmlContent).toContain(''); + expect(htmlContent).toContain(''); + expect(htmlContent).toContain(''); + expect(htmlContent).toContain(''); + expect(htmlContent).toContain(''); + expect(htmlContent).toContain('

Fire emergency response protocol content

'); + }); + + it('should include proper CSS styles for light theme', () => { + render(); + + const webviewContent = screen.getByTestId('webview-content'); + const htmlContent = webviewContent.props.children; + + expect(htmlContent).toContain('color: #1F2937'); // gray-800 for light theme + expect(htmlContent).toContain('background-color: #F9FAFB'); // light theme background + }); + + it('should include responsive CSS', () => { + render(); + + const webviewContent = screen.getByTestId('webview-content'); + const htmlContent = webviewContent.props.children; + + expect(htmlContent).toContain('max-width: 100%'); + expect(htmlContent).toContain('font-family: system-ui, -apple-system, sans-serif'); + }); + }); + + describe('Dark Theme Support', () => { + it('should handle dark theme rendering', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + + render(); + + const webview = screen.getByTestId('webview'); + expect(webview).toBeTruthy(); + + const webviewContent = screen.getByTestId('webview-content'); + expect(webviewContent).toBeTruthy(); + + // The WebView should render with proper HTML structure + expect(webviewContent.props.children).toContain(''); + }); + }); + + describe('Date Display Logic', () => { + it('should prefer UpdatedOn over CreatedOn when both are available', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + + render(); + + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + + it('should fall back to CreatedOn when UpdatedOn is empty', () => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '2', + isDetailsOpen: true, + }); + + render(); + + expect(screen.getByText('2023-01-01 12:00 UTC')).toBeTruthy(); + }); + }); + + describe('HTML Content Handling', () => { + it('should strip HTML tags from description but keep them in WebView', () => { + const protocolWithHtml = { + ...mockProtocols[0], + Description: '

Description with HTML tags

', + ProtocolText: '

Protocol with HTML content

', + }; + + Object.assign(mockProtocolsStore, { + protocols: [protocolWithHtml], + selectedProtocolId: '1', + isDetailsOpen: true, + }); + + render(); + + // Description should be stripped of HTML + expect(screen.getByText('Description with HTML tags')).toBeTruthy(); + + // WebView should contain original HTML + const webviewContent = screen.getByTestId('webview-content'); + expect(webviewContent.props.children).toContain('

Protocol with HTML content

'); + }); + }); + + describe('Accessibility', () => { + beforeEach(() => { + Object.assign(mockProtocolsStore, { + protocols: mockProtocols, + selectedProtocolId: '1', + isDetailsOpen: true, + }); + }); + + it('should be accessible for screen readers', () => { + render(); + + expect(screen.getByText('Fire Emergency Response')).toBeTruthy(); + expect(screen.getByTestId('close-button')).toBeTruthy(); + }); + + it('should support keyboard navigation', () => { + render(); + + const closeButton = screen.getByTestId('close-button'); + fireEvent.press(closeButton); + + expect(mockProtocolsStore.closeDetails).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/protocols/protocol-card.tsx b/src/components/protocols/protocol-card.tsx index 125df91..e9eb4aa 100644 --- a/src/components/protocols/protocol-card.tsx +++ b/src/components/protocols/protocol-card.tsx @@ -16,7 +16,7 @@ interface ProtocolCardProps { export const ProtocolCard: React.FC = ({ protocol, onPress }) => { return ( - onPress(protocol.Id)}> + onPress(protocol.Id)} testID={`protocol-card-${protocol.Id}`}> {protocol.Name} diff --git a/src/components/protocols/protocol-details-sheet.tsx b/src/components/protocols/protocol-details-sheet.tsx index 77c0c31..62bc205 100644 --- a/src/components/protocols/protocol-details-sheet.tsx +++ b/src/components/protocols/protocol-details-sheet.tsx @@ -39,7 +39,7 @@ export const ProtocolDetailsSheet: React.FC = () => { {selectedProtocol.Name} - diff --git a/src/components/roles/__tests__/roles-bottom-sheet.test.tsx b/src/components/roles/__tests__/roles-bottom-sheet.test.tsx new file mode 100644 index 0000000..849d2a0 --- /dev/null +++ b/src/components/roles/__tests__/roles-bottom-sheet.test.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { useCoreStore } from '@/stores/app/core-store'; +import { useRolesStore } from '@/stores/roles/store'; +import { useToastStore } from '@/stores/toast/store'; +import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { type UnitResultData } from '@/models/v4/units/unitResultData'; +import { type UnitRoleResultData } from '@/models/v4/unitRoles/unitRoleResultData'; +import { type ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData'; + +import { RolesBottomSheet } from '../roles-bottom-sheet'; + +// Mock the stores +jest.mock('@/stores/app/core-store'); +jest.mock('@/stores/roles/store'); +jest.mock('@/stores/toast/store'); + +// Mock the CustomBottomSheet component +jest.mock('@/components/ui/bottom-sheet', () => ({ + CustomBottomSheet: ({ children, isOpen }: any) => { + if (!isOpen) return null; + return
{children}
; + }, +})); + +// Mock the RoleAssignmentItem component +jest.mock('../role-assignment-item', () => ({ + RoleAssignmentItem: ({ role }: any) => { + const { Text } = require('react-native'); + return ( + Role: {role.Name} + ); + }, +})); + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => defaultValue || key, + }), +})); + +// Mock nativewind +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), +})); + +// Mock logger +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + }, +})); + +const mockUseCoreStore = useCoreStore as jest.MockedFunction; +const mockUseRolesStore = useRolesStore as jest.MockedFunction; +const mockUseToastStore = useToastStore as jest.MockedFunction; + +describe('RolesBottomSheet', () => { + const mockOnClose = jest.fn(); + const mockFetchRolesForUnit = jest.fn(); + const mockFetchUsers = jest.fn(); + const mockAssignRoles = jest.fn(); + const mockShowToast = jest.fn(); + + const mockActiveUnit: UnitResultData = { + UnitId: 'unit1', + Name: 'Unit 1', + Type: 'Engine', + DepartmentId: 'dept1', + TypeId: 1, + CustomStatusSetId: '', + GroupId: '', + GroupName: '', + Vin: '', + PlateNumber: '', + FourWheelDrive: false, + SpecialPermit: false, + CurrentDestinationId: '', + CurrentStatusId: '', + CurrentStatusTimestamp: '', + Latitude: '', + Longitude: '', + Note: '', + }; + + const mockRoles: UnitRoleResultData[] = [ + { + UnitRoleId: 'role1', + Name: 'Captain', + UnitId: 'unit1', + }, + { + UnitRoleId: 'role2', + Name: 'Engineer', + UnitId: 'unit1', + }, + ]; + + const mockUsers: PersonnelInfoResultData[] = [ + { + UserId: 'user1', + FirstName: 'John', + LastName: 'Doe', + EmailAddress: 'john.doe@example.com', + DepartmentId: 'dept1', + IdentificationNumber: '', + MobilePhone: '', + GroupId: '', + GroupName: '', + StatusId: '', + Status: '', + StatusColor: '', + StatusTimestamp: '', + StatusDestinationId: '', + StatusDestinationName: '', + StaffingId: '', + Staffing: '', + StaffingColor: '', + StaffingTimestamp: '', + Roles: [], + }, + ]; + + const mockUnitRoleAssignments: ActiveUnitRoleResultData[] = [ + { + UnitRoleId: 'role1', + UnitId: 'unit1', + Name: 'Captain', + UserId: 'user1', + FullName: 'John Doe', + UpdatedOn: new Date().toISOString(), + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCoreStore.mockReturnValue(mockActiveUnit); + mockUseRolesStore.mockReturnValue({ + roles: mockRoles, + unitRoleAssignments: mockUnitRoleAssignments, + users: mockUsers, + isLoading: false, + error: null, + fetchRolesForUnit: mockFetchRolesForUnit, + fetchUsers: mockFetchUsers, + assignRoles: mockAssignRoles, + } as any); + + mockUseToastStore.mockReturnValue({ + showToast: mockShowToast, + } as any); + + // Mock the getState functions + useRolesStore.getState = jest.fn().mockReturnValue({ + fetchRolesForUnit: mockFetchRolesForUnit, + fetchUsers: mockFetchUsers, + assignRoles: mockAssignRoles, + }); + + useToastStore.getState = jest.fn().mockReturnValue({ + showToast: mockShowToast, + }); + }); + + it('renders correctly when opened', () => { + render(); + + expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); + expect(screen.getByText('Unit 1')).toBeTruthy(); + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('Save')).toBeTruthy(); + }); + + it('does not render when not opened', () => { + render(); + + expect(screen.queryByText('Unit Role Assignments')).toBeNull(); + }); + + it('fetches roles and users when opened', () => { + render(); + + expect(mockFetchRolesForUnit).toHaveBeenCalledWith('unit1'); + expect(mockFetchUsers).toHaveBeenCalled(); + }); + + it('renders role assignment items', () => { + render(); + + expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); + expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); + }); + + it('displays error state correctly', () => { + const errorMessage = 'Failed to load roles'; + mockUseRolesStore.mockReturnValue({ + roles: [], + unitRoleAssignments: [], + users: [], + isLoading: false, + error: errorMessage, + fetchRolesForUnit: mockFetchRolesForUnit, + fetchUsers: mockFetchUsers, + assignRoles: mockAssignRoles, + } as any); + + render(); + + expect(screen.getByText(errorMessage)).toBeTruthy(); + }); + + it('handles missing active unit gracefully', () => { + mockUseCoreStore.mockReturnValue(null); + + render(); + + expect(screen.getByText('Unit Role Assignments')).toBeTruthy(); + expect(screen.queryByText('Unit 1')).toBeNull(); + }); + + it('filters roles by active unit', () => { + const rolesWithDifferentUnits = [ + ...mockRoles, + { + UnitRoleId: 'role3', + Name: 'Chief', + UnitId: 'unit2', // Different unit + }, + ]; + + mockUseRolesStore.mockReturnValue({ + roles: rolesWithDifferentUnits, + unitRoleAssignments: mockUnitRoleAssignments, + users: mockUsers, + isLoading: false, + error: null, + fetchRolesForUnit: mockFetchRolesForUnit, + fetchUsers: mockFetchUsers, + assignRoles: mockAssignRoles, + } as any); + + render(); + + // Should only show roles for the active unit + expect(screen.getByTestId('role-item-Captain')).toBeTruthy(); + expect(screen.getByTestId('role-item-Engineer')).toBeTruthy(); + expect(screen.queryByTestId('role-item-Chief')).toBeNull(); + }); + + it('has functional buttons', () => { + render(); + + expect(screen.getByText('Cancel')).toBeTruthy(); + expect(screen.getByText('Save')).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/components/roles/role-assignment-item.tsx b/src/components/roles/role-assignment-item.tsx index 009041e..36d8e75 100644 --- a/src/components/roles/role-assignment-item.tsx +++ b/src/components/roles/role-assignment-item.tsx @@ -26,7 +26,7 @@ export const RoleAssignmentItem: React.FC = ({ role, as }); return ( - + {role.Name}