@kenyaemr/esm-admin-app 5.4.5-pre.795 → 5.4.5-pre.798

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.
Files changed (27) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/4124.js +1 -1
  3. package/dist/4124.js.map +1 -1
  4. package/dist/5117.js +1 -1
  5. package/dist/5117.js.map +1 -1
  6. package/dist/5767.js +1 -1
  7. package/dist/5767.js.map +1 -1
  8. package/dist/kenyaemr-esm-admin-app.js.buildmanifest.json +9 -9
  9. package/dist/routes.json +1 -1
  10. package/package.json +1 -1
  11. package/src/components/facility-setup/facility-info.component.test.tsx +116 -0
  12. package/src/components/global-property/dashboard/global-property-dashboard.test.tsx +112 -0
  13. package/src/components/locations/auto-suggest/autosuggest.component.test.tsx +90 -0
  14. package/src/components/locations/auto-suggest/location-autosuggest.component.test.tsx +76 -0
  15. package/src/components/locations/common/results-tile.component.test.tsx +65 -0
  16. package/src/components/modal/hwr-empty.modal.test.tsx +67 -0
  17. package/src/components/provider-banner/provider-banner.component.tsx +35 -23
  18. package/src/components/provider-banner/provider-banner.test.tsx +109 -0
  19. package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-list/user-role-scope-list.component.test.tsx +154 -0
  20. package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope-fields.component.test.tsx +161 -0
  21. package/src/components/users/manage-users/user-details/user-details.component.test.tsx +135 -0
  22. package/src/components/users/manage-users/user-list/user-list.component.test.tsx +172 -0
  23. package/src/components/users/manage-users/user-list/user-list.component.tsx +9 -14
  24. package/src/left-pannel-link.test.tsx +69 -0
  25. package/src/root.component.test.tsx +394 -0
  26. package/src/testing/openmrs-fetch-mock.tsx +253 -0
  27. package/translations/en.json +2 -0
@@ -0,0 +1,172 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework';
5
+ import dayjs from 'dayjs';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { configSchema } from '../../../../config-schema';
8
+ import { useSystemUserRoleConfigSetting } from '../../../hook/useSystemRoleSetting';
9
+ import { useProvider, useUsers } from './user-list.resource';
10
+ import UserList from './user-list.component';
11
+
12
+ vi.mock('./user-list.resource', () => ({
13
+ useProvider: vi.fn(),
14
+ useUsers: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('../../../hook/useSystemRoleSetting', () => ({
18
+ useSystemUserRoleConfigSetting: vi.fn(),
19
+ }));
20
+
21
+ const config = getDefaultsFromConfigSchema(configSchema);
22
+ const users = [
23
+ {
24
+ uuid: 'user-active',
25
+ systemId: 'active-1',
26
+ person: { uuid: 'person-active', display: 'Active Provider', gender: 'F' },
27
+ roles: [],
28
+ },
29
+ {
30
+ uuid: 'user-expiring',
31
+ systemId: 'soon-1',
32
+ person: { uuid: 'person-expiring', display: 'Expiring Provider', gender: 'M' },
33
+ roles: [],
34
+ },
35
+ {
36
+ uuid: 'user-expired',
37
+ systemId: 'expired-1',
38
+ person: { uuid: 'person-expired', display: 'Expired Provider', gender: 'F' },
39
+ roles: [],
40
+ },
41
+ {
42
+ uuid: 'user-unlicensed',
43
+ systemId: 'unlicensed-1',
44
+ person: { uuid: 'person-unlicensed', display: 'Unlicensed Provider', gender: 'M' },
45
+ roles: [],
46
+ },
47
+ ] as ReturnType<typeof useUsers>['users'];
48
+
49
+ const attribute = (uuid: string, value: string) => ({
50
+ uuid: `${uuid}-${value}`,
51
+ attributeType: { uuid, display: uuid },
52
+ value,
53
+ });
54
+
55
+ const providers = [
56
+ {
57
+ uuid: 'provider-active',
58
+ person: { uuid: 'person-active', display: 'Active Provider', gender: 'F' },
59
+ attributes: [
60
+ attribute(config.licenseNumberUuid, 'ACTIVE-123'),
61
+ attribute(config.licenseExpiryDateUuid, dayjs().add(30, 'day').toISOString()),
62
+ ],
63
+ },
64
+ {
65
+ uuid: 'provider-expiring',
66
+ person: { uuid: 'person-expiring', display: 'Expiring Provider', gender: 'M' },
67
+ attributes: [
68
+ attribute(config.licenseNumberUuid, 'SOON-123'),
69
+ attribute(config.licenseExpiryDateUuid, dayjs().add(2, 'day').toISOString()),
70
+ ],
71
+ },
72
+ {
73
+ uuid: 'provider-expired',
74
+ person: { uuid: 'person-expired', display: 'Expired Provider', gender: 'F' },
75
+ attributes: [
76
+ attribute(config.licenseNumberUuid, 'OLD-123'),
77
+ attribute(config.licenseExpiryDateUuid, dayjs().subtract(2, 'day').toISOString()),
78
+ ],
79
+ },
80
+ {
81
+ uuid: 'provider-unlicensed',
82
+ person: { uuid: 'person-unlicensed', display: 'Unlicensed Provider', gender: 'M' },
83
+ attributes: [],
84
+ },
85
+ ] as ReturnType<typeof useProvider>['provider'];
86
+
87
+ const mockUseUsers = vi.mocked(useUsers);
88
+ const mockUseProvider = vi.mocked(useProvider);
89
+
90
+ async function selectFilter(name: string) {
91
+ const user = userEvent.setup();
92
+ await user.click(screen.getByRole('combobox'));
93
+ await user.click(await screen.findByRole('option', { name }));
94
+ }
95
+
96
+ describe('UserList filters and pagination', () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ vi.mocked(useConfig).mockReturnValue(config);
100
+ vi.mocked(useSystemUserRoleConfigSetting).mockReturnValue({
101
+ rolesConfig: [],
102
+ isLoading: false,
103
+ mutate: vi.fn(),
104
+ error: undefined,
105
+ });
106
+ mockUseUsers.mockReturnValue({ users, isLoading: false, error: undefined, mutate: vi.fn() });
107
+ mockUseProvider.mockReturnValue({ provider: providers, isLoading: false, error: undefined });
108
+ });
109
+
110
+ it('filters to active licensed providers', async () => {
111
+ render(<UserList />);
112
+ await selectFilter('Active Licensed');
113
+
114
+ expect(screen.getByText('ACTIVE PROVIDER')).toBeVisible();
115
+ expect(screen.getByText('EXPIRING PROVIDER')).toBeVisible();
116
+ expect(screen.queryByText('EXPIRED PROVIDER')).not.toBeInTheDocument();
117
+ expect(screen.queryByText('UNLICENSED PROVIDER')).not.toBeInTheDocument();
118
+ });
119
+
120
+ it('filters to licenses expiring within three days', async () => {
121
+ render(<UserList />);
122
+ await selectFilter('Licensed expiring soon');
123
+
124
+ expect(screen.getByText('EXPIRING PROVIDER')).toBeVisible();
125
+ expect(screen.queryByText('ACTIVE PROVIDER')).not.toBeInTheDocument();
126
+ expect(screen.queryByText('EXPIRED PROVIDER')).not.toBeInTheDocument();
127
+ });
128
+
129
+ it('filters to providers without national IDs or license numbers', async () => {
130
+ render(<UserList />);
131
+ await selectFilter('Unlicensed');
132
+
133
+ expect(screen.getByText('UNLICENSED PROVIDER')).toBeVisible();
134
+ expect(screen.queryByText('ACTIVE PROVIDER')).not.toBeInTheDocument();
135
+ });
136
+
137
+ it('changes pages through the pagination control', async () => {
138
+ const user = userEvent.setup();
139
+ const paginatedUsers = Array.from({ length: 11 }, (_, index) => ({
140
+ ...users[3],
141
+ uuid: `user-${index + 1}`,
142
+ systemId: `system-${index + 1}`,
143
+ person: {
144
+ ...users[3].person,
145
+ uuid: `person-${index + 1}`,
146
+ display: `Person ${index + 1}`,
147
+ },
148
+ }));
149
+ const paginatedProviders = paginatedUsers.map((user) => ({
150
+ ...providers[3],
151
+ uuid: `provider-${user.uuid}`,
152
+ person: user.person,
153
+ }));
154
+ mockUseUsers.mockReturnValue({
155
+ users: paginatedUsers,
156
+ isLoading: false,
157
+ error: undefined,
158
+ mutate: vi.fn(),
159
+ });
160
+ mockUseProvider.mockReturnValue({ provider: paginatedProviders, isLoading: false, error: undefined });
161
+
162
+ render(<UserList />);
163
+
164
+ expect(screen.getByText('PERSON 1')).toBeVisible();
165
+ expect(screen.queryByText('PERSON 11')).not.toBeInTheDocument();
166
+
167
+ await user.click(screen.getByRole('button', { name: 'Next page' }));
168
+
169
+ expect(await screen.findByText('PERSON 11')).toBeVisible();
170
+ expect(screen.queryByText('PERSON 1')).not.toBeInTheDocument();
171
+ });
172
+ });
@@ -260,20 +260,15 @@ const UserList: React.FC = () => {
260
260
  <OverflowMenuItem
261
261
  className={styles.btn}
262
262
  onClick={() => {
263
- const selectedUser = users.find((u) => u.uuid === user.uuid);
264
- if (selectedUser) {
265
- launchWorkspace2(
266
- 'manage-user-workspace',
267
- {
268
- workspaceTitle: t('editUser', 'Edit User'),
269
- initialUserValue: user,
270
- },
271
- {},
272
- {},
273
- );
274
- } else {
275
- console.error('User not found:', user.uuid);
276
- }
263
+ launchWorkspace2(
264
+ 'manage-user-workspace',
265
+ {
266
+ workspaceTitle: t('editUser', 'Edit User'),
267
+ initialUserValue: user,
268
+ },
269
+ {},
270
+ {},
271
+ );
277
272
  }}
278
273
  itemText={t('editUser', 'Edit user')}
279
274
  />
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom/vitest';
5
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
6
+
7
+ import { createLeftPanelLink } from './left-pannel-link.component';
8
+
9
+ // The global react-i18next mock returns undefined for single-argument t(key)
10
+ // calls, which would leave the links unlabelled; label links by key instead.
11
+ vi.mock('react-i18next', () => ({
12
+ useTranslation: () => ({ t: (key: string) => key }),
13
+ }));
14
+
15
+ // name/title pairs as registered in src/index.ts and src/components/global-property/index.ts
16
+ const registeredLinks = [
17
+ { name: 'user-management', title: 'manageUsers' },
18
+ { name: 'etl-administration', title: 'etlAdministration' },
19
+ { name: 'locations', title: 'locations' },
20
+ { name: 'facility-setup', title: 'facilityDetails' },
21
+ { name: 'global-property', title: 'globalProperty' },
22
+ ];
23
+
24
+ describe('createLeftPanelLink', () => {
25
+ const user = userEvent.setup();
26
+
27
+ beforeEach(() => {
28
+ window.history.pushState({}, '', '/openmrs/spa/admin');
29
+ });
30
+
31
+ it.each(registeredLinks)('renders the $title link pointing at /admin/$name', ({ name, title }) => {
32
+ const LinkComponent = createLeftPanelLink({ name, title });
33
+ render(<LinkComponent />);
34
+
35
+ const link = screen.getByRole('link', { name: title });
36
+ expect(link).toHaveAttribute('href', `/openmrs/spa/admin/${name}`);
37
+ });
38
+
39
+ it.each(registeredLinks)('navigates to /admin/$name when the $title link is clicked', async ({ name, title }) => {
40
+ const LinkComponent = createLeftPanelLink({ name, title });
41
+ render(<LinkComponent />);
42
+
43
+ await user.click(screen.getByRole('link', { name: title }));
44
+ expect(window.location.pathname).toBe(`/openmrs/spa/admin/${name}`);
45
+ });
46
+
47
+ it('marks the link matching the current route as active', () => {
48
+ window.history.pushState({}, '', '/openmrs/spa/admin/locations');
49
+ const LocationsLink = createLeftPanelLink({ name: 'locations', title: 'locations' });
50
+ const FacilityLink = createLeftPanelLink({ name: 'facility-setup', title: 'facilityDetails' });
51
+ render(
52
+ <>
53
+ <LocationsLink />
54
+ <FacilityLink />
55
+ </>,
56
+ );
57
+
58
+ expect(screen.getByRole('link', { name: 'locations' })).toHaveClass('active-left-nav-link');
59
+ expect(screen.getByRole('link', { name: 'facilityDetails' })).not.toHaveClass('active-left-nav-link');
60
+ });
61
+
62
+ it('treats a trailing UUID segment as the user-management route', () => {
63
+ window.history.pushState({}, '', '/openmrs/spa/admin/6eb8d678-514d-46ad-9554-51e48d96d567');
64
+ const UserManagementLink = createLeftPanelLink({ name: 'user-management', title: 'manageUsers' });
65
+ render(<UserManagementLink />);
66
+
67
+ expect(screen.getByRole('link', { name: 'manageUsers' })).toHaveClass('active-left-nav-link');
68
+ });
69
+ });
@@ -0,0 +1,394 @@
1
+ import { screen, waitFor, act } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import '@testing-library/jest-dom/vitest';
4
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
5
+ import { openmrsFetch as apiOpenmrsFetch } from '@openmrs/esm-api';
6
+ import {
7
+ getDefaultsFromConfigSchema,
8
+ launchWorkspace2,
9
+ showModal,
10
+ showSnackbar,
11
+ useConfig,
12
+ useLeftNav,
13
+ } from '@openmrs/esm-framework';
14
+
15
+ import { configSchema } from './config-schema';
16
+ import {
17
+ allFetchCalls,
18
+ allFetchUrls,
19
+ defaultRoutes,
20
+ facilityRegistryFixture,
21
+ installFetchDispatcher,
22
+ renderAdminApp,
23
+ } from './testing/openmrs-fetch-mock';
24
+
25
+ // Network boundary: the real framework hooks running inside the official mock
26
+ // (useFhirFetchAll, useOpenmrsPagination) fetch through @openmrs/esm-api directly,
27
+ // so that instance is spied here; app-level hooks go through the framework mock's
28
+ // own openmrsFetch spy. installFetchDispatcher wires both to one dispatcher.
29
+ vi.mock('@openmrs/esm-api', async (importOriginal) => ({
30
+ ...(await importOriginal<typeof import('@openmrs/esm-api')>()),
31
+ openmrsFetch: vi.fn(),
32
+ }));
33
+
34
+ const apiFetchMock = vi.mocked(apiOpenmrsFetch);
35
+ const mockShowModal = vi.mocked(showModal);
36
+ const mockShowSnackbar = vi.mocked(showSnackbar);
37
+ const mockLaunchWorkspace2 = vi.mocked(launchWorkspace2);
38
+
39
+ describe('Root', () => {
40
+ const user = userEvent.setup();
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ vi.mocked(useConfig).mockReturnValue(getDefaultsFromConfigSchema(configSchema));
45
+ mockShowModal.mockReturnValue(vi.fn());
46
+ installFetchDispatcher(apiFetchMock, defaultRoutes());
47
+ });
48
+
49
+ describe('shell and routing', () => {
50
+ it('registers the left nav and renders the user management landing page at /', async () => {
51
+ renderAdminApp('/');
52
+ expect(useLeftNav).toHaveBeenCalledWith({ name: 'admin-left-panel-slot', basePath: '/spa' });
53
+ expect(await screen.findByText('User Management')).toBeInTheDocument();
54
+ });
55
+
56
+ it('renders the global property dashboard at /global-property', async () => {
57
+ renderAdminApp('/global-property');
58
+ expect(await screen.findByText('setting.one')).toBeInTheDocument();
59
+ expect(allFetchUrls(apiFetchMock)).toEqual(
60
+ expect.arrayContaining([expect.stringContaining('systemsetting?v=default&limit=10')]),
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('/user-management', () => {
66
+ it('loads users and providers and renders them in the provider list table', async () => {
67
+ renderAdminApp('/user-management');
68
+ expect(await screen.findByRole('table', { name: 'Provider list' })).toBeInTheDocument();
69
+
70
+ expect(screen.getByText('ALICE WANJIKU')).toBeInTheDocument();
71
+ expect(screen.getByText('BOB OTIENO')).toBeInTheDocument();
72
+ expect(screen.getByText('CAROL AKINYI')).toBeInTheDocument();
73
+ expect(screen.getByText('A123')).toBeInTheDocument();
74
+ expect(screen.getByText('B456')).toBeInTheDocument();
75
+
76
+ const urls = allFetchUrls(apiFetchMock);
77
+ expect(urls).toEqual(expect.arrayContaining([expect.stringContaining('/ws/rest/v1/user?v=custom')]));
78
+ expect(urls).toEqual(expect.arrayContaining([expect.stringContaining('/ws/rest/v1/provider?v=custom')]));
79
+ expect(urls).toEqual(
80
+ expect.arrayContaining([expect.stringContaining('systemsetting?q=kenyaemr.userRole.config')]),
81
+ );
82
+ });
83
+
84
+ it('filters users by name through the search box', async () => {
85
+ renderAdminApp('/user-management');
86
+ await screen.findByText('ALICE WANJIKU');
87
+
88
+ await user.type(screen.getByPlaceholderText('Search for user'), 'alice');
89
+ expect(screen.getByText('ALICE WANJIKU')).toBeInTheDocument();
90
+ expect(screen.queryByText('BOB OTIENO')).not.toBeInTheDocument();
91
+
92
+ await user.clear(screen.getByPlaceholderText('Search for user'));
93
+ await user.type(screen.getByPlaceholderText('Search for user'), 'zzz');
94
+ expect(await screen.findByText('No matching users found')).toBeInTheDocument();
95
+ });
96
+
97
+ it('filters to expired licensed users via the filter dropdown', async () => {
98
+ renderAdminApp('/user-management');
99
+ await screen.findByText('ALICE WANJIKU');
100
+
101
+ await user.click(screen.getByRole('combobox'));
102
+ await user.click(await screen.findByRole('option', { name: 'Expired Licensed' }));
103
+
104
+ expect(screen.getByText('BOB OTIENO')).toBeInTheDocument();
105
+ expect(screen.queryByText('ALICE WANJIKU')).not.toBeInTheDocument();
106
+ expect(screen.queryByText('CAROL AKINYI')).not.toBeInTheDocument();
107
+ });
108
+
109
+ it('launches the manage-user workspace from the Add User button', async () => {
110
+ renderAdminApp('/user-management');
111
+ await screen.findByText('ALICE WANJIKU');
112
+
113
+ await user.click(screen.getByRole('button', { name: /add user/i }));
114
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
115
+ 'manage-user-workspace',
116
+ expect.objectContaining({ workspaceTitle: 'Add user' }),
117
+ {},
118
+ {},
119
+ );
120
+ });
121
+
122
+ it('opens the edit workspace from the row actions menu', async () => {
123
+ renderAdminApp('/user-management');
124
+ await screen.findByText('ALICE WANJIKU');
125
+
126
+ const menus = screen.getAllByRole('button', { name: 'Options' });
127
+ expect(menus).toHaveLength(3);
128
+
129
+ await user.click(menus[0]);
130
+ await user.click(await screen.findByText('Edit user'));
131
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
132
+ 'manage-user-workspace',
133
+ expect.objectContaining({ initialUserValue: expect.objectContaining({ uuid: 'user-1' }) }),
134
+ {},
135
+ {},
136
+ );
137
+ });
138
+
139
+ it('opens the HWR sync modal for a provider with a national ID', async () => {
140
+ renderAdminApp('/user-management');
141
+ await screen.findByText('ALICE WANJIKU');
142
+
143
+ await user.click(screen.getAllByRole('button', { name: 'Options' })[0]);
144
+ await user.click(await screen.findByText('Sync'));
145
+ expect(mockShowModal).toHaveBeenCalledWith(
146
+ 'hwr-syncing-modal',
147
+ expect.objectContaining({ provider: expect.objectContaining({ uuid: 'provider-1' }) }),
148
+ );
149
+ });
150
+
151
+ it('launches the role scope workspace for a user with an inventory role', async () => {
152
+ renderAdminApp('/user-management');
153
+ await screen.findByText('ALICE WANJIKU');
154
+
155
+ await user.click(screen.getAllByRole('button', { name: 'Options' })[0]);
156
+ await user.click(await screen.findByText('Manage user role scope'));
157
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
158
+ 'user-role-scope-workspace',
159
+ expect.objectContaining({ user: expect.objectContaining({ uuid: 'user-1' }) }),
160
+ {},
161
+ {},
162
+ );
163
+ });
164
+
165
+ it('disables sync and role scope for a user without identifiers or inventory roles', async () => {
166
+ renderAdminApp('/user-management');
167
+ await screen.findByText('ALICE WANJIKU');
168
+
169
+ // Carol has no provider attributes and no inventory role
170
+ await user.click(screen.getAllByRole('button', { name: 'Options' })[2]);
171
+ const sync = await screen.findByText('Sync');
172
+ expect(sync.closest('button')).toBeDisabled();
173
+ expect(screen.getByText('Manage user role scope').closest('button')).toBeDisabled();
174
+ });
175
+
176
+ it('shows user details with the license status when a row is expanded', async () => {
177
+ renderAdminApp('/user-management');
178
+ await screen.findByText('ALICE WANJIKU');
179
+
180
+ await user.click(screen.getAllByRole('button', { name: /expand current row/i })[0]);
181
+ expect(await screen.findByText('Patient Photo')).toBeInTheDocument();
182
+ expect(screen.getByText('Active')).toBeInTheDocument();
183
+ });
184
+
185
+ it('shows the empty state when there are no users', async () => {
186
+ installFetchDispatcher(apiFetchMock, [
187
+ { match: '/ws/rest/v1/user?v=', response: { data: { results: [] } } },
188
+ { match: '/ws/rest/v1/provider?v=', response: { data: { results: [] } } },
189
+ ...defaultRoutes(),
190
+ ]);
191
+ renderAdminApp('/user-management');
192
+ expect(await screen.findByText('No users available')).toBeInTheDocument();
193
+ });
194
+
195
+ it('shows the error state when the users request fails', async () => {
196
+ installFetchDispatcher(apiFetchMock, [
197
+ { match: '/ws/rest/v1/user?v=', error: new Error('Server error') },
198
+ ...defaultRoutes(),
199
+ ]);
200
+ renderAdminApp('/user-management');
201
+ expect(await screen.findByText('Error State')).toBeInTheDocument();
202
+ });
203
+ });
204
+
205
+ describe('/etl-administration', () => {
206
+ it('renders the ETL dashboard without firing any operation on mount', async () => {
207
+ renderAdminApp('/etl-administration');
208
+ expect(await screen.findByText('ETL Administration')).toBeInTheDocument();
209
+ expect(screen.getByRole('button', { name: 'ETL operations' })).toBeInTheDocument();
210
+ expect(allFetchUrls(apiFetchMock).some((url) => url.includes('kemrchart'))).toBe(false);
211
+ });
212
+
213
+ it('runs recreate tables after confirming the operation modal', async () => {
214
+ renderAdminApp('/etl-administration');
215
+ await screen.findByRole('button', { name: 'ETL operations' });
216
+
217
+ await user.click(document.querySelector('.cds--combo-button__trigger') as HTMLElement);
218
+ await user.click(await screen.findByTitle('Recreate tables'));
219
+
220
+ expect(mockShowModal).toHaveBeenCalledWith(
221
+ 'operation-confirmation-modal',
222
+ expect.objectContaining({ operationName: 'Recreate tables' }),
223
+ );
224
+ const [, modalProps] = mockShowModal.mock.calls.at(-1) as [string, { confirm: () => Promise<void> }];
225
+
226
+ await act(async () => {
227
+ await modalProps.confirm();
228
+ });
229
+
230
+ expect(allFetchUrls(apiFetchMock)).toEqual(
231
+ expect.arrayContaining([expect.stringContaining('/ws/rest/v1/kemrchart/recreateTables')]),
232
+ );
233
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
234
+ expect(await screen.findByText('sp_update_etl_tables')).toBeInTheDocument();
235
+ });
236
+
237
+ it('shows an error snackbar when refresh tables fails', async () => {
238
+ installFetchDispatcher(apiFetchMock, [
239
+ { match: /kemrchart\//, error: { responseBody: { error: { message: 'ETL failure' } } } },
240
+ ...defaultRoutes(),
241
+ ]);
242
+ renderAdminApp('/etl-administration');
243
+ await screen.findByRole('button', { name: 'ETL operations' });
244
+
245
+ await user.click(document.querySelector('.cds--combo-button__trigger') as HTMLElement);
246
+ await user.click(await screen.findByTitle('Refresh tables'));
247
+ const [, modalProps] = mockShowModal.mock.calls.at(-1) as [string, { confirm: () => Promise<void> }];
248
+
249
+ await act(async () => {
250
+ await modalProps.confirm();
251
+ });
252
+
253
+ expect(allFetchUrls(apiFetchMock)).toEqual(
254
+ expect.arrayContaining([expect.stringContaining('/ws/rest/v1/kemrchart/refreshTables')]),
255
+ );
256
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
257
+ });
258
+
259
+ it('does not run the operation when the modal is dismissed', async () => {
260
+ renderAdminApp('/etl-administration');
261
+ await screen.findByRole('button', { name: 'ETL operations' });
262
+
263
+ await user.click(document.querySelector('.cds--combo-button__trigger') as HTMLElement);
264
+ await user.click(await screen.findByTitle('Recreate tables'));
265
+ const [, modalProps] = mockShowModal.mock.calls.at(-1) as [string, { close: () => void }];
266
+
267
+ act(() => modalProps.close());
268
+
269
+ expect(allFetchUrls(apiFetchMock).some((url) => url.includes('kemrchart'))).toBe(false);
270
+ expect(mockShowSnackbar).not.toHaveBeenCalled();
271
+ });
272
+ });
273
+
274
+ describe('/facility-setup', () => {
275
+ it('renders the synced facility record in section cards', async () => {
276
+ renderAdminApp('/facility-setup');
277
+
278
+ expect(await screen.findByText('General information')).toBeInTheDocument();
279
+ expect(screen.getByText('License & SHA status')).toBeInTheDocument();
280
+ expect(screen.getByText('Contact & administrator')).toBeInTheDocument();
281
+ expect(screen.getByText('Bed occupancy')).toBeInTheDocument();
282
+ expect(screen.getByText(facilityRegistryFixture.official_name)).toBeInTheDocument();
283
+ expect(screen.getByText(facilityRegistryFixture.fr_code)).toBeInTheDocument();
284
+
285
+ expect(allFetchUrls(apiFetchMock)).toEqual(
286
+ expect.arrayContaining([expect.stringContaining('/ws/rest/v1/virtualclaims/facility-registry')]),
287
+ );
288
+ });
289
+
290
+ it('offers Sync now when the facility is not yet synced, then renders the pulled record', async () => {
291
+ let synced = false;
292
+ installFetchDispatcher(apiFetchMock, [
293
+ {
294
+ match: 'facility-registry/sync',
295
+ response: () => {
296
+ synced = true;
297
+ return { data: facilityRegistryFixture };
298
+ },
299
+ },
300
+ {
301
+ match: 'virtualclaims/facility-registry',
302
+ response: () => {
303
+ if (!synced) {
304
+ throw Object.assign(new Error('Not found'), { response: { status: 404 } });
305
+ }
306
+ return { data: facilityRegistryFixture };
307
+ },
308
+ },
309
+ ...defaultRoutes(),
310
+ ]);
311
+ renderAdminApp('/facility-setup');
312
+
313
+ expect(await screen.findByText('Facility details')).toBeInTheDocument();
314
+ expect(
315
+ screen.getByText('This facility has not been pulled from the registry. Click sync to fetch the latest record.'),
316
+ ).toBeInTheDocument();
317
+
318
+ await user.click(screen.getByRole('button', { name: /sync now/i }));
319
+
320
+ const syncCall = allFetchCalls(apiFetchMock).find(([url]) => url.includes('facility-registry/sync'));
321
+ expect(syncCall?.[1]).toEqual(expect.objectContaining({ method: 'POST' }));
322
+ await waitFor(() => {
323
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
324
+ });
325
+ expect(await screen.findByText('General information')).toBeInTheDocument();
326
+ });
327
+
328
+ it('shows the error tile with retry when the registry request hard-fails', async () => {
329
+ installFetchDispatcher(apiFetchMock, [
330
+ { match: 'facility-registry/sync', error: { responseBody: { error: 'Registry unreachable' } } },
331
+ {
332
+ match: 'virtualclaims/facility-registry',
333
+ error: Object.assign(new Error('Server error'), { response: { status: 500 } }),
334
+ },
335
+ ...defaultRoutes(),
336
+ ]);
337
+ renderAdminApp('/facility-setup');
338
+
339
+ expect(await screen.findByText('Failed to load the facility record.')).toBeInTheDocument();
340
+
341
+ await user.click(screen.getByRole('button', { name: /retry sync/i }));
342
+ await waitFor(() => {
343
+ expect(mockShowSnackbar).toHaveBeenCalledWith(
344
+ expect.objectContaining({ kind: 'error', subtitle: 'Registry unreachable' }),
345
+ );
346
+ });
347
+ });
348
+ });
349
+
350
+ describe('/locations', () => {
351
+ it('loads location tags then the tagged FHIR locations into the table', async () => {
352
+ renderAdminApp('/locations');
353
+
354
+ expect(await screen.findByText('Mbagathi Hospital')).toBeInTheDocument();
355
+ expect(screen.getByText('Kibera Clinic')).toBeInTheDocument();
356
+ expect(screen.getByText('Facility')).toBeInTheDocument();
357
+ expect(screen.getByText('Clinic')).toBeInTheDocument();
358
+
359
+ const urls = allFetchUrls(apiFetchMock);
360
+ expect(urls).toEqual(
361
+ expect.arrayContaining([
362
+ expect.stringContaining('/ws/rest/v1/locationtag?v=custom:(uuid,display,name,description)'),
363
+ ]),
364
+ );
365
+ expect(urls).toEqual(
366
+ expect.arrayContaining([expect.stringMatching(/fhir2\/R4\/Location\?_summary=data&_tag=Facility,Clinic/)]),
367
+ );
368
+ });
369
+
370
+ it('filters locations through the search box', async () => {
371
+ renderAdminApp('/locations');
372
+ await screen.findByText('Mbagathi Hospital');
373
+
374
+ await user.type(screen.getByPlaceholderText('Search location'), 'Kibera');
375
+ expect(screen.getByText('Kibera Clinic')).toBeInTheDocument();
376
+ expect(screen.queryByText('Mbagathi Hospital')).not.toBeInTheDocument();
377
+ });
378
+
379
+ it('launches the add and tag location workspaces', async () => {
380
+ renderAdminApp('/locations');
381
+ await screen.findByText('Mbagathi Hospital');
382
+
383
+ await user.click(screen.getByRole('button', { name: /add location/i }));
384
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith('add-location-workspace', {
385
+ workspaceProps: { workspaceTitle: 'Add Location' },
386
+ });
387
+
388
+ await user.click(screen.getByRole('button', { name: /tag location/i }));
389
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith('search-location-workspace', {
390
+ workspaceProps: { workspaceTitle: 'Tag Location' },
391
+ });
392
+ });
393
+ });
394
+ });