@cccsaurora/howler-ui 2.19.0-cases.1095 → 2.19.0-cases.1110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ // ---------------------------------------------------------------------------
7
+ // Hoisted mocks
8
+ // ---------------------------------------------------------------------------
9
+ const mockHitCardProps = vi.hoisted(() => ({ current: [] }));
10
+ // ---------------------------------------------------------------------------
11
+ // Module mocks
12
+ // ---------------------------------------------------------------------------
13
+ vi.mock('components/elements/hit/HitCard', () => ({
14
+ default: (props) => {
15
+ mockHitCardProps.current.push(props);
16
+ return _jsx("div", { id: `hit-card-${String(props.id)}` });
17
+ }
18
+ }));
19
+ vi.mock('react-i18next', () => ({
20
+ useTranslation: () => ({
21
+ t: (key) => key
22
+ })
23
+ }));
24
+ vi.mock('react-router-dom', async () => {
25
+ const actual = await vi.importActual('react-router-dom');
26
+ return {
27
+ ...actual,
28
+ Link: ({ to, children, ...props }) => (_jsx("a", { href: to, ...props, children: children }))
29
+ };
30
+ });
31
+ // ---------------------------------------------------------------------------
32
+ // Imports after mocks
33
+ // ---------------------------------------------------------------------------
34
+ import AlertPanel from './AlertPanel';
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+ const renderPanel = (caseValue) => {
39
+ return render(_jsx(MemoryRouter, { children: _jsx(AlertPanel, { case: caseValue }) }));
40
+ };
41
+ const makeHitItem = (id, path = `/cases/test/${id}`) => ({
42
+ type: 'hit',
43
+ value: id,
44
+ path
45
+ });
46
+ // ---------------------------------------------------------------------------
47
+ // Setup
48
+ // ---------------------------------------------------------------------------
49
+ beforeEach(() => {
50
+ mockHitCardProps.current = [];
51
+ });
52
+ // ---------------------------------------------------------------------------
53
+ // Tests
54
+ // ---------------------------------------------------------------------------
55
+ describe('AlertPanel', () => {
56
+ it('renders a skeleton when the case is null', () => {
57
+ const { container } = renderPanel(null);
58
+ expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
59
+ });
60
+ it('renders the translated heading key', () => {
61
+ const _case = createMockCase({ case_id: 'case-1', items: [] });
62
+ renderPanel(_case);
63
+ expect(screen.getByText('page.cases.dashboard.alerts')).toBeInTheDocument();
64
+ });
65
+ it('renders HitCard only for unique hit items on the current page', () => {
66
+ const duplicate = makeHitItem('hit-1', '/cases/test/path-a');
67
+ const _case = createMockCase({
68
+ case_id: 'case-2',
69
+ items: [
70
+ duplicate,
71
+ duplicate,
72
+ makeHitItem('hit-2', '/cases/test/path-b'),
73
+ { type: 'event', value: 'event-1', path: '/cases/test/event-1' }
74
+ ]
75
+ });
76
+ renderPanel(_case);
77
+ expect(screen.getByTestId('hit-card-hit-1')).toBeInTheDocument();
78
+ expect(screen.getByTestId('hit-card-hit-2')).toBeInTheDocument();
79
+ expect(screen.queryByTestId('hit-card-event-1')).not.toBeInTheDocument();
80
+ expect(mockHitCardProps.current).toHaveLength(2);
81
+ expect(mockHitCardProps.current[0]).toEqual(expect.objectContaining({
82
+ id: 'hit-1',
83
+ lazy: true,
84
+ layout: 'dense'
85
+ }));
86
+ });
87
+ it('renders overlay links that target each hit path', () => {
88
+ const _case = createMockCase({
89
+ case_id: 'case-3',
90
+ items: [makeHitItem('hit-1', '/cases/case-3/path-one'), makeHitItem('hit-2', '/cases/case-3/path-two')]
91
+ });
92
+ const { container } = renderPanel(_case);
93
+ const links = Array.from(container.querySelectorAll('a'));
94
+ expect(links).toHaveLength(2);
95
+ expect(links[0]).toHaveAttribute('href', '/cases/case-3/path-one');
96
+ expect(links[1]).toHaveAttribute('href', '/cases/case-3/path-two');
97
+ });
98
+ it('shows pagination with multiple pages and switches to page 2 items', () => {
99
+ const items = Array.from({ length: 6 }, (_, idx) => makeHitItem(`hit-${idx + 1}`, `/cases/test/hit-${idx + 1}`));
100
+ const _case = createMockCase({ case_id: 'case-4', items });
101
+ renderPanel(_case);
102
+ expect(screen.getByTestId('hit-card-hit-1')).toBeInTheDocument();
103
+ expect(screen.getByTestId('hit-card-hit-5')).toBeInTheDocument();
104
+ expect(screen.queryByTestId('hit-card-hit-6')).not.toBeInTheDocument();
105
+ const pageTwoButton = screen.getByRole('button', { name: 'Go to page 2' });
106
+ fireEvent.click(pageTwoButton);
107
+ expect(screen.getByTestId('hit-card-hit-6')).toBeInTheDocument();
108
+ expect(screen.queryByTestId('hit-card-hit-1')).not.toBeInTheDocument();
109
+ });
110
+ });
@@ -0,0 +1,218 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
4
+ import { useState } from 'react';
5
+ import { createMockCase, createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ // ---------------------------------------------------------------------------
8
+ // Hoisted mocks
9
+ // ---------------------------------------------------------------------------
10
+ const mockDispatchApi = vi.hoisted(() => vi.fn());
11
+ const mockSearchPost = vi.hoisted(() => vi.fn());
12
+ const mockUpdateCase = vi.hoisted(() => vi.fn());
13
+ const mockUseCaseState = vi.hoisted(() => ({ case: null }));
14
+ const mockAggregateProps = vi.hoisted(() => ({
15
+ current: []
16
+ }));
17
+ const mockCaseOverviewProps = vi.hoisted(() => ({ current: [] }));
18
+ const mockTaskPanelProps = vi.hoisted(() => ({ current: [] }));
19
+ const mockAlertPanelProps = vi.hoisted(() => ({ current: [] }));
20
+ const mockRelatedCasePanelProps = vi.hoisted(() => ({ current: [] }));
21
+ // ---------------------------------------------------------------------------
22
+ // Module mocks
23
+ // ---------------------------------------------------------------------------
24
+ vi.mock('use-context-selector', async () => {
25
+ const react = (await vi.importActual('react'));
26
+ return {
27
+ createContext: react.createContext,
28
+ useContextSelector: (context, selector) => {
29
+ return selector(react.useContext(context));
30
+ }
31
+ };
32
+ });
33
+ vi.mock('components/hooks/useMyApi', () => ({
34
+ default: () => ({ dispatchApi: mockDispatchApi })
35
+ }));
36
+ vi.mock('api', () => ({
37
+ default: {
38
+ v2: {
39
+ search: {
40
+ post: (...args) => mockSearchPost(...args)
41
+ }
42
+ }
43
+ }
44
+ }));
45
+ vi.mock('../hooks/useCase', () => ({
46
+ default: () => ({
47
+ case: mockUseCaseState.case,
48
+ update: mockUpdateCase
49
+ })
50
+ }));
51
+ vi.mock('react-i18next', () => ({
52
+ useTranslation: () => ({
53
+ t: (key) => key
54
+ })
55
+ }));
56
+ vi.mock('react-router-dom', async () => {
57
+ const actual = await vi.importActual('react-router-dom');
58
+ return {
59
+ ...actual,
60
+ useOutletContext: () => createMockCase({ case_id: 'route-case-id', items: [] })
61
+ };
62
+ });
63
+ vi.mock('./aggregates/CaseAggregate', () => ({
64
+ default: (props) => {
65
+ mockAggregateProps.current.push(props);
66
+ return _jsx("div", { id: `aggregate-${String(mockAggregateProps.current.length)}` });
67
+ }
68
+ }));
69
+ vi.mock('./CaseOverview', () => ({
70
+ default: (props) => {
71
+ mockCaseOverviewProps.current.push(props);
72
+ return _jsx("div", { id: "case-overview" });
73
+ }
74
+ }));
75
+ vi.mock('./TaskPanel', () => ({
76
+ default: (props) => {
77
+ mockTaskPanelProps.current.push(props);
78
+ return _jsx("div", { id: "task-panel" });
79
+ }
80
+ }));
81
+ vi.mock('./AlertPanel', () => ({
82
+ default: (props) => {
83
+ mockAlertPanelProps.current.push(props);
84
+ return _jsx("div", { id: "alert-panel" });
85
+ }
86
+ }));
87
+ vi.mock('./RelatedCasePanel', () => ({
88
+ default: (props) => {
89
+ mockRelatedCasePanelProps.current.push(props);
90
+ return _jsx("div", { id: "related-case-panel" });
91
+ }
92
+ }));
93
+ // ---------------------------------------------------------------------------
94
+ // Imports after mocks
95
+ // ---------------------------------------------------------------------------
96
+ import CaseDashboard from './CaseDashboard';
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+ const makeRecordContextValue = (records, loadRecords) => ({
101
+ records,
102
+ selectedRecords: [],
103
+ addRecordToSelection: vi.fn(),
104
+ removeRecordFromSelection: vi.fn(),
105
+ clearSelectedRecords: vi.fn(),
106
+ loadRecords,
107
+ updateRecord: vi.fn(),
108
+ getRecord: vi.fn()
109
+ });
110
+ const renderDashboard = ({ dashboardCase, initialRecords = {}, onLoadRecords }) => {
111
+ const Wrapper = ({ children }) => {
112
+ const [records, setRecords] = useState(initialRecords);
113
+ const loadRecords = items => {
114
+ onLoadRecords?.(items);
115
+ const mapped = Object.fromEntries(items.map(item => [item.howler.id, item]));
116
+ setRecords(prev => ({ ...prev, ...mapped }));
117
+ };
118
+ return (_jsx(RecordContext.Provider, { value: makeRecordContextValue(records, loadRecords), children: children }));
119
+ };
120
+ return render(_jsx(CaseDashboard, { case: dashboardCase }), { wrapper: Wrapper });
121
+ };
122
+ // ---------------------------------------------------------------------------
123
+ // Setup
124
+ // ---------------------------------------------------------------------------
125
+ beforeEach(() => {
126
+ mockDispatchApi.mockReset();
127
+ mockSearchPost.mockReset();
128
+ mockUpdateCase.mockReset();
129
+ mockAggregateProps.current = [];
130
+ mockCaseOverviewProps.current = [];
131
+ mockTaskPanelProps.current = [];
132
+ mockAlertPanelProps.current = [];
133
+ mockRelatedCasePanelProps.current = [];
134
+ mockUseCaseState.case = null;
135
+ });
136
+ // ---------------------------------------------------------------------------
137
+ // Tests
138
+ // ---------------------------------------------------------------------------
139
+ describe('CaseDashboard', () => {
140
+ it('renders nothing when useCase does not return a case', () => {
141
+ const baseCase = createMockCase({ case_id: 'case-1', items: [] });
142
+ mockUseCaseState.case = null;
143
+ const { container } = renderDashboard({ dashboardCase: baseCase });
144
+ expect(container).toBeEmptyDOMElement();
145
+ });
146
+ it('renders overview, panels and aggregate cards with translated subtitles', () => {
147
+ const dashboardCase = createMockCase({
148
+ case_id: 'case-2',
149
+ items: [
150
+ { type: 'hit', value: 'hit-1', path: 'root/hit-1' },
151
+ { type: 'event', value: 'event-1', path: 'root/event-1' }
152
+ ]
153
+ });
154
+ mockUseCaseState.case = dashboardCase;
155
+ renderDashboard({
156
+ dashboardCase,
157
+ initialRecords: {
158
+ 'hit-1': createMockHit({ howler: { id: 'hit-1', outline: { threat: 'threat-a', target: 'target-a' } } }),
159
+ 'event-1': createMockEvent({ howler: { id: 'event-1', outline: { indicators: ['ioc-a'] } } })
160
+ }
161
+ });
162
+ expect(mockCaseOverviewProps.current).toHaveLength(1);
163
+ expect(mockTaskPanelProps.current).toHaveLength(1);
164
+ expect(mockAlertPanelProps.current).toHaveLength(1);
165
+ expect(mockRelatedCasePanelProps.current).toHaveLength(1);
166
+ expect(mockAggregateProps.current).toHaveLength(4);
167
+ expect(mockAggregateProps.current[0]).toEqual(expect.objectContaining({
168
+ field: 'howler.outline.threat',
169
+ subtitle: 'page.cases.dashboard.threat'
170
+ }));
171
+ expect(mockAggregateProps.current[1]).toEqual(expect.objectContaining({
172
+ field: 'howler.outline.target',
173
+ subtitle: 'page.cases.dashboard.target'
174
+ }));
175
+ expect(mockAggregateProps.current[2]).toEqual(expect.objectContaining({
176
+ field: 'howler.outline.indicators',
177
+ subtitle: 'page.cases.dashboard.indicators'
178
+ }));
179
+ expect(mockAggregateProps.current[3]).toEqual(expect.objectContaining({
180
+ subtitle: 'page.cases.dashboard.duration',
181
+ title: '--'
182
+ }));
183
+ });
184
+ it('loads missing hit and event records and sends expected search query', async () => {
185
+ const dashboardCase = createMockCase({
186
+ case_id: 'case-3',
187
+ items: [
188
+ { type: 'hit', value: 'hit-1', path: 'root/hit-1' },
189
+ { type: 'event', value: 'event-1', path: 'root/event-1' },
190
+ { type: 'hit', value: 'hit-2', path: 'root/hit-2' },
191
+ { type: 'case', value: 'case-child', path: 'root/case-child' }
192
+ ]
193
+ });
194
+ mockUseCaseState.case = dashboardCase;
195
+ const loadedEvent = createMockEvent({ howler: { id: 'event-1' } });
196
+ const loadedHit = createMockHit({ howler: { id: 'hit-2' } });
197
+ mockSearchPost.mockReturnValue({ endpoint: 'search' });
198
+ mockDispatchApi.mockResolvedValue({
199
+ items: [loadedEvent, loadedHit]
200
+ });
201
+ const onLoadRecords = vi.fn();
202
+ renderDashboard({
203
+ dashboardCase,
204
+ initialRecords: {
205
+ 'hit-1': createMockHit({ howler: { id: 'hit-1' } })
206
+ },
207
+ onLoadRecords
208
+ });
209
+ await waitFor(() => {
210
+ expect(mockSearchPost).toHaveBeenCalledWith(['hit', 'event'], {
211
+ query: 'howler.id:(event-1 OR hit-2)',
212
+ metadata: ['template', 'analytic']
213
+ });
214
+ expect(mockDispatchApi).toHaveBeenCalledTimes(1);
215
+ expect(onLoadRecords).toHaveBeenCalledWith([loadedEvent, loadedHit]);
216
+ });
217
+ });
218
+ });
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Check, FormatListBulleted, HourglassBottom, Pause, People, WarningRounded } from '@mui/icons-material';
3
- import { Autocomplete, AvatarGroup, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
2
+ import { Check, FormatListBulleted, HourglassBottom, Pause, People, TrendingUp, WarningRounded } from '@mui/icons-material';
3
+ import { Autocomplete, AvatarGroup, Card, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
4
4
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
5
5
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
6
  import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
@@ -10,6 +10,7 @@ import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
10
10
  import dayjs from 'dayjs';
11
11
  import { useContext, useState } from 'react';
12
12
  import { useTranslation } from 'react-i18next';
13
+ import { ESCALATION_COLOR_MAP } from '../constants';
13
14
  import useCase from '../hooks/useCase';
14
15
  import ResolveModal from '../modals/ResolveModal';
15
16
  import SourceAggregate from './aggregates/SourceAggregate';
@@ -61,10 +62,18 @@ const CaseDetails = ({ case: providedCase }) => {
61
62
  'in-progress': _jsx(HourglassBottom, { color: "warning" }),
62
63
  'on-hold': _jsx(Pause, { color: "disabled" }),
63
64
  resolved: _jsx(Check, { color: "success" })
64
- }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => handleStatus(status) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading }), _jsx("div", { style: { flex: 1 } })] }), caseViewers.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(SocketBadge, { size: "medium" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.viewers') })] }), _jsx(AvatarGroup, { max: 4, sx: { alignSelf: 'start' }, componentsProps: {
65
+ }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => {
66
+ if (status) {
67
+ handleStatus(status);
68
+ }
69
+ } }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(TrendingUp, { color: ESCALATION_COLOR_MAP[_case.escalation] }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.escalation') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.escalation ?? null, options: config.lookups['case.escalation'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, escalation) => {
70
+ if (escalation) {
71
+ wrappedUpdate({ escalation });
72
+ }
73
+ } })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading }), _jsx("div", { style: { flex: 1 } })] }), caseViewers.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(SocketBadge, { size: "medium" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.viewers') })] }), _jsx(AvatarGroup, { max: 4, sx: { alignSelf: 'start' }, componentsProps: {
65
74
  additionalAvatar: {
66
75
  sx: { height: 32, width: 32, fontSize: '12px' }
67
76
  }
68
- }, children: caseViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: 32, width: 32 } }, viewer))) })] }))] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.escalation') }) }), _jsx(TableCell, { children: _jsx(Chip, { size: "small", label: _case.escalation }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
77
+ }, children: caseViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: 32, width: 32 } }, viewer))) })] }))] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
69
78
  };
70
79
  export default CaseDetails;
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
7
+ import {} from 'react';
8
+ import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ // ---------------------------------------------------------------------------
12
+ // Hoisted stubs
13
+ // ---------------------------------------------------------------------------
14
+ const mockUpdate = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
15
+ const mockShowModal = vi.hoisted(() => vi.fn());
16
+ // ---------------------------------------------------------------------------
17
+ // Module mocks (registered before the dynamic import below)
18
+ // ---------------------------------------------------------------------------
19
+ vi.mock('../hooks/useCase', () => ({
20
+ default: ({ case: c }) => ({
21
+ case: c,
22
+ update: mockUpdate,
23
+ loading: false,
24
+ missing: false
25
+ })
26
+ }));
27
+ vi.mock('components/app/providers/ModalProvider', async () => {
28
+ const { createContext } = await import('react');
29
+ return {
30
+ ModalContext: createContext({
31
+ showModal: mockShowModal,
32
+ close: vi.fn(),
33
+ setContent: vi.fn(),
34
+ withConfirmDeleteModal: vi.fn()
35
+ })
36
+ };
37
+ });
38
+ vi.mock('components/elements/UserList', () => ({
39
+ default: () => _jsx("div", { id: "user-list" })
40
+ }));
41
+ vi.mock('components/elements/display/HowlerAvatar', () => ({
42
+ default: ({ userId }) => _jsx("div", { children: userId })
43
+ }));
44
+ vi.mock('components/elements/display/icons/SocketBadge', () => ({
45
+ default: () => _jsx("span", {})
46
+ }));
47
+ vi.mock('./aggregates/SourceAggregate', () => ({
48
+ default: () => _jsx("span", {})
49
+ }));
50
+ vi.mock('../modals/ResolveModal', () => ({
51
+ default: () => null
52
+ }));
53
+ // ---------------------------------------------------------------------------
54
+ // Shared provider config
55
+ // ---------------------------------------------------------------------------
56
+ const mockConfig = {
57
+ lookups: {
58
+ 'howler.status': ['open', 'in-progress', 'on-hold', 'resolved'],
59
+ 'case.escalation': ['normal', 'focus', 'crisis'],
60
+ 'howler.escalation': ['miss', 'hit', 'alert', 'evidence']
61
+ }
62
+ };
63
+ const mockViewers = {};
64
+ const Wrapper = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsx(SocketContext.Provider, { value: {
65
+ emit: vi.fn(),
66
+ open: true,
67
+ fetchViewers: vi.fn(),
68
+ addListener: vi.fn(),
69
+ removeListener: vi.fn(),
70
+ status: 1,
71
+ reconnect: vi.fn(),
72
+ viewers: { ...mockViewers }
73
+ }, children: children }) }));
74
+ // ---------------------------------------------------------------------------
75
+ // Import after mocks
76
+ // ---------------------------------------------------------------------------
77
+ const { default: CaseDetails } = await import('./CaseDetails');
78
+ // ---------------------------------------------------------------------------
79
+ // Tests
80
+ // ---------------------------------------------------------------------------
81
+ describe('CaseDetails', () => {
82
+ let user;
83
+ let testCase;
84
+ beforeEach(() => {
85
+ user = userEvent.setup();
86
+ vi.clearAllMocks();
87
+ mockUpdate.mockResolvedValue(undefined);
88
+ testCase = createMockCase({ case_id: 'test-case-id', status: 'open', escalation: 'normal' });
89
+ Object.keys(mockViewers).forEach(k => delete mockViewers[k]);
90
+ });
91
+ it('renders a skeleton and no controls when the case is null', () => {
92
+ render(_jsx(CaseDetails, { case: null }), { wrapper: Wrapper });
93
+ expect(document.querySelector('.MuiSkeleton-root')).toBeTruthy();
94
+ expect(screen.queryByRole('combobox')).toBeNull();
95
+ });
96
+ it('renders the status label and current status value', () => {
97
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
98
+ expect(screen.getByText('page.cases.detail.status')).toBeInTheDocument();
99
+ expect(screen.getByDisplayValue('open')).toBeInTheDocument();
100
+ });
101
+ it('renders the escalation label and current escalation value', () => {
102
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
103
+ expect(screen.getByText('page.cases.detail.escalation')).toBeInTheDocument();
104
+ expect(screen.getByDisplayValue('normal')).toBeInTheDocument();
105
+ });
106
+ describe('status changes', () => {
107
+ it('calls update with the new status when a non-resolved option is selected', async () => {
108
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
109
+ await user.click(screen.getByDisplayValue('open'));
110
+ await user.click(await screen.findByRole('option', { name: 'in-progress' }));
111
+ await waitFor(() => {
112
+ expect(mockUpdate).toHaveBeenCalledWith({ status: 'in-progress' });
113
+ });
114
+ });
115
+ it('opens the resolve modal instead of calling update when "resolved" is selected', async () => {
116
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
117
+ await user.click(screen.getByDisplayValue('open'));
118
+ await user.click(await screen.findByRole('option', { name: 'resolved' }));
119
+ expect(mockShowModal).toHaveBeenCalledOnce();
120
+ expect(mockUpdate).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+ describe('escalation changes', () => {
124
+ it('calls update with the new escalation when an option is selected', async () => {
125
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
126
+ await user.click(screen.getByDisplayValue('normal'));
127
+ await user.click(await screen.findByRole('option', { name: 'focus' }));
128
+ await waitFor(() => {
129
+ expect(mockUpdate).toHaveBeenCalledWith({ escalation: 'focus' });
130
+ });
131
+ });
132
+ });
133
+ describe('viewers section', () => {
134
+ it('hides the viewers section when no viewers are active', () => {
135
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
136
+ expect(screen.queryByText('page.cases.detail.viewers')).toBeNull();
137
+ });
138
+ it('shows the viewers section and renders viewer avatars when active viewers are present', () => {
139
+ mockViewers['test-case-id'] = ['user-1', 'user-2'];
140
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
141
+ expect(screen.getByText('page.cases.detail.viewers')).toBeInTheDocument();
142
+ expect(screen.getByText('user-1')).toBeInTheDocument();
143
+ expect(screen.getByText('user-2')).toBeInTheDocument();
144
+ });
145
+ });
146
+ });
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Properties",
422
422
  "page.cases.detail.status": "Status",
423
+ "page.cases.detail.escalation": "Escalation",
423
424
  "page.cases.detail.viewers": "Active Viewers",
424
425
  "page.cases.escalation": "Escalation",
425
426
  "page.cases.folder.drop.root": "Place here to move to root",
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Propriétés",
422
422
  "page.cases.detail.status": "Statut",
423
+ "page.cases.detail.escalation": "Escalade",
423
424
  "page.cases.detail.viewers": "Spectateurs actifs",
424
425
  "page.cases.escalation": "Escalade",
425
426
  "page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-cases.1095",
99
+ "version": "2.19.0-cases.1110",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",