@ampath/esm-dha-workflow-app 4.0.0-next.340 → 4.0.0-next.344

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 (128) hide show
  1. package/dist/1149.js +1 -1
  2. package/dist/1171.js +1 -0
  3. package/dist/1171.js.map +1 -0
  4. package/dist/1177.js +1 -0
  5. package/dist/1177.js.map +1 -0
  6. package/dist/1239.js +1 -0
  7. package/dist/1239.js.map +1 -0
  8. package/dist/1551.js +1 -1
  9. package/dist/1560.js +1 -0
  10. package/dist/1560.js.map +1 -0
  11. package/dist/1928.js +1 -0
  12. package/dist/1928.js.map +1 -0
  13. package/dist/2093.js +1 -1
  14. package/dist/2230.js +1 -1
  15. package/dist/2493.js +1 -1
  16. package/dist/2914.js +1 -1
  17. package/dist/3078.js +1 -0
  18. package/dist/3078.js.map +1 -0
  19. package/dist/327.js +1 -0
  20. package/dist/327.js.map +1 -0
  21. package/dist/4605.js +1 -1
  22. package/dist/4611.js +1 -1
  23. package/dist/4674.js +1 -1
  24. package/dist/4674.js.map +1 -1
  25. package/dist/4986.js +1 -1
  26. package/dist/5167.js +1 -1
  27. package/dist/5317.js +1 -0
  28. package/dist/5317.js.map +1 -0
  29. package/dist/5344.js +1 -0
  30. package/dist/{2936.js.map → 5344.js.map} +1 -1
  31. package/dist/5765.js +1 -0
  32. package/dist/5765.js.map +1 -0
  33. package/dist/5851.js +1 -1
  34. package/dist/5851.js.map +1 -1
  35. package/dist/5961.js +1 -1
  36. package/dist/6264.js +1 -1
  37. package/dist/6264.js.map +1 -1
  38. package/dist/6720.js +1 -1
  39. package/dist/6942.js +1 -1
  40. package/dist/6942.js.map +1 -1
  41. package/dist/7521.js +1 -1
  42. package/dist/7521.js.map +1 -1
  43. package/dist/7549.js +1 -0
  44. package/dist/7549.js.map +1 -0
  45. package/dist/7717.js +1 -1
  46. package/dist/7857.js +1 -0
  47. package/dist/7857.js.map +1 -0
  48. package/dist/850.js +1 -0
  49. package/dist/850.js.map +1 -0
  50. package/dist/8733.js +1 -1
  51. package/dist/9070.js +1 -1
  52. package/dist/9093.js +1 -1
  53. package/dist/9093.js.map +1 -1
  54. package/dist/9338.js +1 -1
  55. package/dist/9338.js.map +1 -1
  56. package/dist/9349.js +1 -0
  57. package/dist/9349.js.map +1 -0
  58. package/dist/9428.js +1 -1
  59. package/dist/9428.js.map +1 -1
  60. package/dist/9789.js +1 -0
  61. package/dist/9789.js.map +1 -0
  62. package/dist/9871.js +1 -0
  63. package/dist/9871.js.map +1 -0
  64. package/dist/esm-dha-workflow-app.js +1 -1
  65. package/dist/esm-dha-workflow-app.js.buildmanifest.json +362 -239
  66. package/dist/esm-dha-workflow-app.js.map +1 -1
  67. package/dist/main.js +1 -1
  68. package/dist/main.js.map +1 -1
  69. package/dist/routes.json +1 -1
  70. package/package.json +1 -1
  71. package/src/billing/billing-claims.resource.ts +8 -7
  72. package/src/billing/dashboard/v2/clearance/cash.component.tsx +9 -9
  73. package/src/billing/dashboard/v2/clearance/pay-cash.component.tsx +24 -22
  74. package/src/billing/dashboard/v2/facility-bills/facility-bills.component.tsx +5 -4
  75. package/src/billing/dashboard/v2/patient-bill-details/attachments/final-bill.component.tsx +2 -2
  76. package/src/billing/dashboard/v2/patient-bill-details/attachments/generate-attachments.component.tsx +15 -4
  77. package/src/billing/dashboard/v2/patient-bill-details/attachments/invoice.component.tsx +2 -2
  78. package/src/billing/dashboard/v2/types/index.ts +6 -0
  79. package/src/billing/dashboard/v3/claim-visits/claim-intervention-details/claim-intervention-details.component.tsx +6 -1
  80. package/src/billing/dashboard/v3/claim-visits/claim-visit-details/claim-visit-details.component.tsx +5 -8
  81. package/src/billing/dashboard/v3/facility-bills/facility-bills.component.tsx +65 -62
  82. package/src/billing/dashboard/v3/patient-bill-details/attachments/case-summary/case-summary.tsx +30 -16
  83. package/src/billing/dashboard/v3/patient-bill-details/attachments/generate-attachments.component.tsx +3 -1
  84. package/src/billing/dashboard/v3/patient-bill-details/bill-details/bill-details.scss +4 -0
  85. package/src/billing/dashboard/v3/patient-bill-details/bill-details/bill-details.tsx +154 -156
  86. package/src/billing/dashboard/v3/patient-bill-details/claim-details/patient-claim-details.component.tsx +22 -23
  87. package/src/billing/dashboard/v3/patient-bill-details/patient-bill-details.tsx +5 -4
  88. package/src/dashboard/dashboard.component.tsx +3 -2
  89. package/src/dashboard/overview/overview.component.tsx +3 -1
  90. package/src/dashboard-meta/emt-dashboard.meta.ts +6 -0
  91. package/src/emt/cr-lookup.resource.test.ts +88 -0
  92. package/src/emt/cr-lookup.resource.ts +42 -0
  93. package/src/emt/emt-queue.component.test.tsx +324 -0
  94. package/src/emt/emt-queue.component.tsx +449 -0
  95. package/src/emt/emt-queue.scss +83 -0
  96. package/src/emt/emt.resource.test.ts +247 -0
  97. package/src/emt/emt.resource.ts +150 -0
  98. package/src/emt/handover-modal/handover-modal.component.test.tsx +273 -0
  99. package/src/emt/handover-modal/handover-modal.component.tsx +524 -0
  100. package/src/emt/handover-modal/handover-modal.scss +88 -0
  101. package/src/emt/types/emt.types.ts +137 -0
  102. package/src/index.ts +10 -0
  103. package/src/registry/registry.component.tsx +49 -1
  104. package/src/registry/utils/hie-client-adapter.ts +1 -1
  105. package/src/root.component.tsx +2 -0
  106. package/src/routes.json +25 -0
  107. package/src/side-nav-menu/nav-link-config.ts +4 -0
  108. package/dist/1362.js +0 -1
  109. package/dist/1362.js.map +0 -1
  110. package/dist/203.js +0 -1
  111. package/dist/203.js.map +0 -1
  112. package/dist/2200.js +0 -1
  113. package/dist/2200.js.map +0 -1
  114. package/dist/2936.js +0 -1
  115. package/dist/2988.js +0 -1
  116. package/dist/2988.js.map +0 -1
  117. package/dist/307.js +0 -1
  118. package/dist/307.js.map +0 -1
  119. package/dist/4008.js +0 -1
  120. package/dist/4008.js.map +0 -1
  121. package/dist/446.js +0 -1
  122. package/dist/446.js.map +0 -1
  123. package/dist/5097.js +0 -1
  124. package/dist/5097.js.map +0 -1
  125. package/dist/7240.js +0 -1
  126. package/dist/7240.js.map +0 -1
  127. package/dist/874.js +0 -1
  128. package/dist/874.js.map +0 -1
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tests for the EMT resource layer: fetchPendingReferrals, initiateHandover,
3
+ * verifyHandoverOtp, and normalizeError.
4
+ *
5
+ * We mock `../shared/utils/get-base-url` (`getHieBaseUrl`) and
6
+ * `@openmrs/esm-framework` (`openmrsFetch`) directly since `emt.resource.ts`
7
+ * calls them — this avoids loading the heavy OpenMRS module graph.
8
+ */
9
+ jest.mock('../shared/utils/get-base-url', () => ({
10
+ getHieBaseUrl: jest.fn(),
11
+ }));
12
+
13
+ jest.mock('@openmrs/esm-framework', () => ({
14
+ openmrsFetch: jest.fn(),
15
+ }));
16
+
17
+ import { openmrsFetch } from '@openmrs/esm-framework';
18
+ import { getHieBaseUrl } from '../shared/utils/get-base-url';
19
+ import {
20
+ fetchPendingReferrals,
21
+ initiateHandover,
22
+ verifyHandoverOtp,
23
+ normalizeError,
24
+ getHandoverRequestId,
25
+ } from './emt.resource';
26
+ import { EmtApiError } from './types/emt.types';
27
+
28
+ const mockOpenmrsFetch = jest.mocked(openmrsFetch);
29
+ const mockGetHieBaseUrl = jest.mocked(getHieBaseUrl);
30
+
31
+ const BASE_URL = 'http://localhost:3000';
32
+
33
+ const referralFixture = {
34
+ submission_id: 3,
35
+ cr_id: 'CR5617849204955-8',
36
+ status: 'pending_acceptance',
37
+ incident_id: 'INC-20260803100645-254727092999-ffjotq',
38
+ dispatch_id: 'd22419d8-6d36-4b2f-a33c-3e008bd85f77',
39
+ case_number: 'AMB-d22419d8-6d36-4b2f-a33c-3e008bd85f77-FAC',
40
+ ambulance_fr_code: 'FID-AMB-916293-3',
41
+ ambulance_registration_number: 'KDN 085T',
42
+ facility_fr_code: 'FID-47-108521-3',
43
+ evacuation_scene: '',
44
+ priority: 'p1 life threatening (als) with altered consciousness',
45
+ referral_reason: '',
46
+ referral_category: '',
47
+ transport_modality: '',
48
+ referral_notes: 'Chief complaint: Test.',
49
+ bundle_id: 'd22419d8-6d36-4b2f-a33c-3e008bd85f77',
50
+ interventions: ['SHA-01-001'],
51
+ requested_at: '2026-08-04T09:37:39.438967Z',
52
+ updated_at: '2026-08-04T09:37:40.428903Z',
53
+ };
54
+
55
+ const listFixture = {
56
+ results: [referralFixture],
57
+ count: 1,
58
+ limit: 50,
59
+ offset: 0,
60
+ };
61
+
62
+ /** Mimics what `openmrsFetch` actually throws for a non-2xx response. */
63
+ function fetchError(status: number, message = `Server responded with ${status}`) {
64
+ return { response: { status }, message };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ jest.resetAllMocks();
69
+ mockGetHieBaseUrl.mockResolvedValue(BASE_URL);
70
+ });
71
+
72
+ // ── fetchPendingReferrals ─────────────────────────────────────────────
73
+
74
+ describe('fetchPendingReferrals', () => {
75
+ it('requests the referrals endpoint with limit, offset, and the facility locationUuid', async () => {
76
+ mockOpenmrsFetch.mockResolvedValueOnce({ data: listFixture } as any);
77
+
78
+ const data = await fetchPendingReferrals(50, 0, 'location-uuid-1');
79
+
80
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
81
+ `${BASE_URL}/emt/referrals?limit=50&offset=0&locationUuid=location-uuid-1`,
82
+ { method: 'GET' },
83
+ );
84
+ expect(data.results).toHaveLength(1);
85
+ expect(data.results[0].cr_id).toBe('CR5617849204955-8');
86
+ expect(data.count).toBe(1);
87
+ });
88
+
89
+ it('normalises a malformed envelope gracefully', async () => {
90
+ mockOpenmrsFetch.mockResolvedValueOnce({ data: { results: undefined, count: 'bad' } } as any);
91
+
92
+ const data = await fetchPendingReferrals(undefined, undefined, 'location-uuid-1');
93
+ expect(data.results).toEqual([]);
94
+ expect(data.count).toBe(0);
95
+ });
96
+
97
+ it('re-throws as an EmtApiError carrying the real HTTP status', async () => {
98
+ mockOpenmrsFetch.mockRejectedValueOnce(fetchError(401, 'Token expired'));
99
+
100
+ await expect(fetchPendingReferrals()).rejects.toMatchObject({ status: 401 });
101
+ });
102
+ });
103
+
104
+ // ── initiateHandover ───────────────────────────────────────────────────
105
+
106
+ describe('initiateHandover', () => {
107
+ it('POSTs to /handover/initiate with the request body', async () => {
108
+ const responseFixture = { request_id: '82fd22b6-e366-4077-9866-e1c4ed7328b0' };
109
+ mockOpenmrsFetch.mockResolvedValueOnce({ data: responseFixture } as any);
110
+
111
+ const res = await initiateHandover({
112
+ incidenceNumber: 'AMB-d22419d8-FAC',
113
+ identifier: 'A13579',
114
+ identifier_type: 'registration_number',
115
+ regulator: 'KMPDC',
116
+ locationUuid: 'location-uuid-1',
117
+ });
118
+
119
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
120
+ `${BASE_URL}/emt/handover/initiate`,
121
+ {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: {
125
+ incidenceNumber: 'AMB-d22419d8-FAC',
126
+ identifier: 'A13579',
127
+ identifier_type: 'registration_number',
128
+ regulator: 'KMPDC',
129
+ locationUuid: 'location-uuid-1',
130
+ },
131
+ },
132
+ );
133
+ expect(res.request_id).toBe('82fd22b6-e366-4077-9866-e1c4ed7328b0');
134
+ });
135
+
136
+ it('wraps a 409 conflict as EmtApiError(409)', async () => {
137
+ mockOpenmrsFetch.mockRejectedValueOnce(fetchError(409, 'Handover already initiated'));
138
+
139
+ try {
140
+ await initiateHandover({
141
+ incidenceNumber: 'AMB-d22419d8-FAC',
142
+ identifier: 'A13579',
143
+ identifier_type: 'registration_number',
144
+ regulator: 'KMPDC',
145
+ locationUuid: 'location-uuid-1',
146
+ });
147
+ fail('Should have thrown');
148
+ } catch (err) {
149
+ expect(err).toBeInstanceOf(EmtApiError);
150
+ expect((err as EmtApiError).status).toBe(409);
151
+ }
152
+ });
153
+ });
154
+
155
+ // ── verifyHandoverOtp ──────────────────────────────────────────────────
156
+
157
+ describe('verifyHandoverOtp', () => {
158
+ it('POSTs to /handover/verify with incidenceNumber, request_id, and otp', async () => {
159
+ mockOpenmrsFetch.mockResolvedValueOnce({ data: { status: 'verified' } } as any);
160
+
161
+ await verifyHandoverOtp({
162
+ incidenceNumber: 'AMB-d22419d8-FAC',
163
+ request_id: '82fd22b6-e366-4077-9866-e1c4ed7328b0',
164
+ otp: '623415',
165
+ locationUuid: 'location-uuid-1',
166
+ });
167
+
168
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
169
+ `${BASE_URL}/emt/handover/verify`,
170
+ {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: {
174
+ incidenceNumber: 'AMB-d22419d8-FAC',
175
+ request_id: '82fd22b6-e366-4077-9866-e1c4ed7328b0',
176
+ otp: '623415',
177
+ locationUuid: 'location-uuid-1',
178
+ },
179
+ },
180
+ );
181
+ });
182
+
183
+ it('wraps an expired/invalid OTP (410) as EmtApiError(410)', async () => {
184
+ mockOpenmrsFetch.mockRejectedValueOnce(fetchError(410, 'OTP expired'));
185
+
186
+ try {
187
+ await verifyHandoverOtp({
188
+ incidenceNumber: 'AMB-d22419d8-FAC',
189
+ request_id: '82fd22b6-e366-4077-9866-e1c4ed7328b0',
190
+ otp: '000000',
191
+ locationUuid: 'location-uuid-1',
192
+ });
193
+ fail('Should have thrown');
194
+ } catch (err) {
195
+ expect(err).toBeInstanceOf(EmtApiError);
196
+ expect((err as EmtApiError).status).toBe(410);
197
+ }
198
+ });
199
+ });
200
+
201
+ // ── normalizeError ────────────────────────────────────────────────────
202
+
203
+ describe('normalizeError', () => {
204
+ it('reads the real HTTP status off an openmrsFetch-style error', () => {
205
+ const err = normalizeError(fetchError(404, 'Case not found'));
206
+ expect(err).toBeInstanceOf(EmtApiError);
207
+ expect(err.status).toBe(404);
208
+ expect(err.message).toBe('Case not found');
209
+ });
210
+
211
+ it('prefers a JSON responseBody.message when present', () => {
212
+ const err = normalizeError({
213
+ response: { status: 422 },
214
+ message: 'Server responded with 422',
215
+ responseBody: { message: 'identifier is required' },
216
+ });
217
+ expect(err.status).toBe(422);
218
+ expect(err.message).toBe('identifier is required');
219
+ });
220
+
221
+ it('returns status 0 for network / non-HTTP errors', () => {
222
+ const err = normalizeError(new Error('NetworkError: Failed to fetch'));
223
+ expect(err).toBeInstanceOf(EmtApiError);
224
+ expect(err.status).toBe(0);
225
+ });
226
+
227
+ it('passes an already-typed EmtApiError through unchanged', () => {
228
+ const original = new EmtApiError(409, 'Already handled');
229
+ expect(normalizeError(original)).toBe(original);
230
+ });
231
+ });
232
+
233
+ // ── getHandoverRequestId ──────────────────────────────────────────────
234
+
235
+ describe('getHandoverRequestId', () => {
236
+ it('returns request_id (snake_case)', () => {
237
+ expect(getHandoverRequestId({ request_id: 'abc' })).toBe('abc');
238
+ });
239
+
240
+ it('falls back to requestId (camelCase)', () => {
241
+ expect(getHandoverRequestId({ requestId: 'def' })).toBe('def');
242
+ });
243
+
244
+ it('returns empty string when neither is present', () => {
245
+ expect(getHandoverRequestId({})).toBe('');
246
+ });
247
+ });
@@ -0,0 +1,150 @@
1
+ import useSWR from 'swr';
2
+ import { openmrsFetch } from '@openmrs/esm-framework';
3
+ import { getHieBaseUrl } from '../shared/utils/get-base-url';
4
+ import {
5
+ EmtApiError,
6
+ type EmtReferral,
7
+ type EmtReferralListResponse,
8
+ type InitiateHandoverRequest,
9
+ type InitiateHandoverResponse,
10
+ type VerifyHandoverRequest,
11
+ } from './types/emt.types';
12
+
13
+ /**
14
+ * EMT / Referral data source.
15
+ *
16
+ * Endpoints are relative to the configured HIE base URL — `getHieBaseUrl()`,
17
+ * the same helper `registry.resource.ts`'s CR search and `preauth.resource.ts`'s
18
+ * HWR search already use — and go through `openmrsFetch`, so auth rides the
19
+ * real, current OpenMRS session instead of a hand-copied, expiring token.
20
+ */
21
+
22
+ const EMT_BASE = '/emt';
23
+
24
+ /** SWR cache key prefix for the pending-referrals list (used for invalidation). */
25
+ export const EMT_PENDING_KEY = 'emt-pending-referrals';
26
+
27
+ /** Default auto-refresh interval for the queue. SWR is the polling mechanism. */
28
+ export const EMT_POLL_INTERVAL_MS = 30_000;
29
+
30
+ /**
31
+ * Fetch a page of pending EMT referrals.
32
+ *
33
+ * The endpoint is server-paginated (`limit`/`offset`/`count`) — callers should
34
+ * page against the returned `count`, not assume a single page.
35
+ */
36
+ export async function fetchPendingReferrals(
37
+ limit = 50,
38
+ offset = 0,
39
+ locationUuid = '',
40
+ ): Promise<EmtReferralListResponse> {
41
+ try {
42
+ const hieBaseUrl = await getHieBaseUrl();
43
+ const response = await openmrsFetch<EmtReferralListResponse>(
44
+ `${hieBaseUrl}${EMT_BASE}/referrals?limit=${limit}&offset=${offset}&locationUuid=${encodeURIComponent(locationUuid)}`,
45
+ { method: 'GET' },
46
+ );
47
+ const data = response?.data;
48
+ // Defensively normalise — never let a malformed envelope crash the queue.
49
+ return {
50
+ results: Array.isArray(data?.results) ? data.results : [],
51
+ count: typeof data?.count === 'number' ? data.count : data?.results?.length ?? 0,
52
+ limit: typeof data?.limit === 'number' ? data.limit : limit,
53
+ offset: typeof data?.offset === 'number' ? data.offset : offset,
54
+ };
55
+ } catch (err) {
56
+ throw normalizeError(err);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * SWR hook for the pending-referrals queue.
62
+ *
63
+ * Polls on `EMT_POLL_INTERVAL_MS` (the repo has no global queue-polling
64
+ * convention — SWR focus/reconnect revalidation is the default; here we add an
65
+ * explicit refresh interval since EMT referrals arrive in real time).
66
+ */
67
+ export function usePendingReferrals(limit = 50, offset = 0, locationUuid = '') {
68
+ const key = `${EMT_PENDING_KEY}:${limit}:${offset}:${locationUuid}`;
69
+ const { data, error, isLoading, isValidating, mutate } = useSWR(
70
+ key,
71
+ () => fetchPendingReferrals(limit, offset, locationUuid),
72
+ {
73
+ refreshInterval: EMT_POLL_INTERVAL_MS,
74
+ keepPreviousData: true,
75
+ },
76
+ );
77
+
78
+ return {
79
+ referrals: data?.results ?? [],
80
+ count: data?.count ?? 0,
81
+ limit: data?.limit ?? limit,
82
+ offset: data?.offset ?? offset,
83
+ isLoading,
84
+ isValidating,
85
+ error,
86
+ mutate,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Initiate a handover for a referral. The OTP is sent to the receiving doctor.
92
+ * Returns the `request_id` needed to call `verifyHandoverOtp`.
93
+ */
94
+ export async function initiateHandover(
95
+ payload: InitiateHandoverRequest,
96
+ ): Promise<InitiateHandoverResponse> {
97
+ try {
98
+ const hieBaseUrl = await getHieBaseUrl();
99
+ const response = await openmrsFetch<InitiateHandoverResponse>(
100
+ `${hieBaseUrl}${EMT_BASE}/handover/initiate`,
101
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload },
102
+ );
103
+ return response?.data;
104
+ } catch (err) {
105
+ throw normalizeError(err);
106
+ }
107
+ }
108
+
109
+ /** Extract the handover request id regardless of the response's casing. */
110
+ export function getHandoverRequestId(res: InitiateHandoverResponse): string {
111
+ return res?.request_id ?? res?.requestId ?? '';
112
+ }
113
+
114
+ /**
115
+ * Verify the OTP entered by the receiving doctor and complete the handover.
116
+ * On success the referral is no longer `pending_acceptance` and the patient's
117
+ * visit should be started.
118
+ */
119
+ export async function verifyHandoverOtp(payload: VerifyHandoverRequest) {
120
+ try {
121
+ const hieBaseUrl = await getHieBaseUrl();
122
+ const response = await openmrsFetch(`${hieBaseUrl}${EMT_BASE}/handover/verify`, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: payload,
126
+ });
127
+ return response?.data;
128
+ } catch (err) {
129
+ throw normalizeError(err);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Map an upstream failure into a typed `EmtApiError` carrying the HTTP status,
135
+ * so the UI can branch on the distinct cases (auth, already-handled, conflict,
136
+ * validation, OTP, …). `openmrsFetch` throws `OpenmrsFetchError`, which carries
137
+ * the real `response.status` — no message-parsing needed. Anything without a
138
+ * status (a raw network failure) is treated as status 0.
139
+ */
140
+ export function normalizeError(err: any): EmtApiError {
141
+ if (err instanceof EmtApiError) {
142
+ return err;
143
+ }
144
+ const status = typeof err?.response?.status === 'number' ? err.response.status : 0;
145
+ const message: string =
146
+ (typeof err?.responseBody === 'object' && err?.responseBody?.message) ||
147
+ (typeof err?.message === 'string' && err.message) ||
148
+ 'Unexpected error from the EMT service.';
149
+ return new EmtApiError(status, message);
150
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Tests for the OTP-verified handover flow: resolving the receiving doctor
3
+ * (provider + HWR search), initiate success/failure, verify success/failure,
4
+ * and the "already handled elsewhere" (404) race condition on both calls.
5
+ */
6
+ import React from 'react';
7
+ import { act, render, screen, waitFor } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+
10
+ const mockShowSnackbar = jest.fn();
11
+ jest.mock('@openmrs/esm-framework', () => ({
12
+ showSnackbar: (...args: unknown[]) => mockShowSnackbar(...args),
13
+ }));
14
+
15
+ const mockInitiateHandover = jest.fn();
16
+ const mockVerifyHandoverOtp = jest.fn();
17
+ jest.mock('../emt.resource', () => ({
18
+ initiateHandover: (...args: unknown[]) => mockInitiateHandover(...args),
19
+ verifyHandoverOtp: (...args: unknown[]) => mockVerifyHandoverOtp(...args),
20
+ getHandoverRequestId: (res: any) => res?.request_id ?? res?.requestId ?? '',
21
+ }));
22
+
23
+ const mockSearchOpenMrsProviders = jest.fn();
24
+ const mockSearchHealthWorkerRegistry = jest.fn();
25
+ jest.mock('../../billing/dashboard/v3/preauth/preauth.resource', () => ({
26
+ searchOpenMrsProviders: (...args: unknown[]) => mockSearchOpenMrsProviders(...args),
27
+ searchHealthWorkerRegistry: (...args: unknown[]) => mockSearchHealthWorkerRegistry(...args),
28
+ }));
29
+
30
+ import { EmtApiError, type EmtReferralRow } from '../types/emt.types';
31
+ import HandoverModal from './handover-modal.component';
32
+
33
+ const referral: EmtReferralRow = {
34
+ submission_id: 3,
35
+ cr_id: 'CR5617849204955-8',
36
+ status: 'pending_acceptance',
37
+ incident_id: 'INC-20260803100645-254727092999-ffjotq',
38
+ dispatch_id: 'd22419d8-6d36-4b2f-a33c-3e008bd85f77',
39
+ case_number: 'AMB-d22419d8-FAC',
40
+ ambulance_fr_code: 'FID-AMB-916293-3',
41
+ ambulance_registration_number: 'KDN 085T',
42
+ facility_fr_code: 'FID-47-108521-3',
43
+ evacuation_scene: '',
44
+ priority: 'p1 life threatening (als) with altered consciousness',
45
+ referral_reason: '',
46
+ referral_category: '',
47
+ transport_modality: '',
48
+ referral_notes: 'Chief complaint: Test.',
49
+ bundle_id: 'd22419d8-6d36-4b2f-a33c-3e008bd85f77',
50
+ interventions: ['SHA-01-001'],
51
+ requested_at: '2026-08-04T09:37:39.438967Z',
52
+ updated_at: '2026-08-04T09:37:40.428903Z',
53
+ patientName: 'Jane Doe',
54
+ crLoading: false,
55
+ };
56
+
57
+ const providerHit = {
58
+ uuid: 'provider-uuid-1',
59
+ display: 'Dr. Alice Mwangi',
60
+ identifier: 'PROV-1',
61
+ nationalId: '12345678',
62
+ };
63
+
64
+ const hwrHitFixture = {
65
+ membership: {
66
+ full_name: 'Dr. Alice Mwangi',
67
+ registration_id: 'A13579',
68
+ licensing_body: 'Kenya Medical Practitioners and Dentists Council',
69
+ },
70
+ };
71
+
72
+ function baseProps() {
73
+ return {
74
+ referral,
75
+ locationUuid: 'location-uuid-1',
76
+ closeWorkspace: jest.fn(),
77
+ promptBeforeClosing: jest.fn(),
78
+ onHandoverComplete: jest.fn(),
79
+ onReferralUnavailable: jest.fn(),
80
+ };
81
+ }
82
+
83
+ /** Drives the modal from the initial "doctor" step through to "confirm". */
84
+ async function resolveDoctorAndContinue(user: ReturnType<typeof userEvent.setup>) {
85
+ mockSearchOpenMrsProviders.mockResolvedValueOnce([providerHit]);
86
+ mockSearchHealthWorkerRegistry.mockResolvedValueOnce([hwrHitFixture]);
87
+
88
+ await user.type(screen.getByPlaceholderText(/type at least 2 characters/i), 'Alice');
89
+ const option = await screen.findByText(/Dr\. Alice Mwangi/, {}, { timeout: 2000 });
90
+ await user.click(option);
91
+
92
+ await screen.findByText(/A13579/);
93
+ await waitFor(() => expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled());
94
+ await user.click(screen.getByRole('button', { name: /continue/i }));
95
+
96
+ await screen.findByText(/confirm handover/i);
97
+ }
98
+
99
+ function typeOtp(container: HTMLElement, digits: string) {
100
+ const inputs = Array.from(container.querySelectorAll('input[id^="otp-"]')) as HTMLInputElement[];
101
+ act(() => {
102
+ digits.split('').forEach((digit, i) => {
103
+ fireEventChange(inputs[i], digit);
104
+ });
105
+ });
106
+ }
107
+
108
+ // OTPInput expects a native change event; userEvent.type works too but this is
109
+ // faster and avoids per-keystroke debounce concerns in this digit widget.
110
+ function fireEventChange(input: HTMLInputElement, value: string) {
111
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
112
+ nativeInputValueSetter.call(input, value);
113
+ input.dispatchEvent(new Event('input', { bubbles: true }));
114
+ }
115
+
116
+ beforeEach(() => {
117
+ jest.clearAllMocks();
118
+ });
119
+
120
+ describe('doctor resolution', () => {
121
+ it('resolves the receiving doctor via provider + HWR search and gates Continue on it', async () => {
122
+ const user = userEvent.setup();
123
+ render(<HandoverModal {...baseProps()} />);
124
+
125
+ expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
126
+ await resolveDoctorAndContinue(user);
127
+
128
+ expect(mockSearchHealthWorkerRegistry).toHaveBeenCalledWith({
129
+ identifierType: 'National ID',
130
+ identifierValue: '12345678',
131
+ locationUuid: 'location-uuid-1',
132
+ });
133
+ expect(screen.getByText(/KMPDC/)).toBeInTheDocument();
134
+ expect(screen.getByText(/A13579/)).toBeInTheDocument();
135
+ });
136
+
137
+ it('lets staff proceed to a manual National ID search when the provider has none on file', async () => {
138
+ mockSearchOpenMrsProviders.mockResolvedValueOnce([{ uuid: 'p2', display: 'Dr. No Id' }]);
139
+ const user = userEvent.setup();
140
+ render(<HandoverModal {...baseProps()} />);
141
+
142
+ await user.type(screen.getByPlaceholderText(/type at least 2 characters/i), 'No Id');
143
+ await user.click(await screen.findByText('Dr. No Id'));
144
+
145
+ expect(await screen.findByText(/enter it manually and search hwr/i)).toBeInTheDocument();
146
+ expect(mockSearchHealthWorkerRegistry).not.toHaveBeenCalled();
147
+
148
+ mockSearchHealthWorkerRegistry.mockResolvedValueOnce([hwrHitFixture]);
149
+ await user.type(screen.getByLabelText(/doctor national id/i), '99999999');
150
+ await user.click(screen.getByRole('button', { name: /search hwr/i }));
151
+
152
+ await screen.findByText(/A13579/);
153
+ expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled();
154
+ });
155
+
156
+ it('keeps Continue disabled when the HWR hit has no registration number', async () => {
157
+ mockSearchOpenMrsProviders.mockResolvedValueOnce([providerHit]);
158
+ mockSearchHealthWorkerRegistry.mockResolvedValueOnce([{ membership: { full_name: 'Dr. No Reg' } }]);
159
+ const user = userEvent.setup();
160
+ render(<HandoverModal {...baseProps()} />);
161
+
162
+ await user.type(screen.getByPlaceholderText(/type at least 2 characters/i), 'Alice');
163
+ await user.click(await screen.findByText(/Dr\. Alice Mwangi/));
164
+
165
+ expect(await screen.findByText(/no registration number on file/i)).toBeInTheDocument();
166
+ expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
167
+ });
168
+ });
169
+
170
+ describe('initiate', () => {
171
+ it('on success: sends the exact request body and moves to the OTP step', async () => {
172
+ mockInitiateHandover.mockResolvedValueOnce({ request_id: 'req-123' });
173
+ const user = userEvent.setup();
174
+ render(<HandoverModal {...baseProps()} />);
175
+ await resolveDoctorAndContinue(user);
176
+
177
+ await user.click(screen.getByRole('button', { name: /send otp/i }));
178
+
179
+ expect(mockInitiateHandover).toHaveBeenCalledWith({
180
+ incidenceNumber: 'AMB-d22419d8-FAC',
181
+ identifier: 'A13579',
182
+ identifier_type: 'registration_number',
183
+ regulator: 'KMPDC',
184
+ locationUuid: 'location-uuid-1',
185
+ });
186
+ expect(await screen.findByText(/enter doctor otp/i)).toBeInTheDocument();
187
+ });
188
+
189
+ it('on a 409 conflict: shows a distinct inline message and stays on confirm', async () => {
190
+ mockInitiateHandover.mockRejectedValueOnce(new EmtApiError(409, 'Request failed with 409'));
191
+ const user = userEvent.setup();
192
+ render(<HandoverModal {...baseProps()} />);
193
+ await resolveDoctorAndContinue(user);
194
+
195
+ await user.click(screen.getByRole('button', { name: /send otp/i }));
196
+
197
+ expect(await screen.findByText(/already been initiated or completed/i)).toBeInTheDocument();
198
+ expect(screen.getByText(/confirm handover/i)).toBeInTheDocument();
199
+ });
200
+
201
+ it('on 404 (already handled elsewhere): notifies the parent instead of showing an inline error', async () => {
202
+ mockInitiateHandover.mockRejectedValueOnce(new EmtApiError(404, 'Request failed with 404'));
203
+ const props = baseProps();
204
+ const user = userEvent.setup();
205
+ render(<HandoverModal {...props} />);
206
+ await resolveDoctorAndContinue(user);
207
+
208
+ await user.click(screen.getByRole('button', { name: /send otp/i }));
209
+
210
+ await waitFor(() => expect(props.onReferralUnavailable).toHaveBeenCalledWith(referral, expect.stringMatching(/handled elsewhere/i)));
211
+ expect(screen.queryByText(/already been initiated or completed/i)).not.toBeInTheDocument();
212
+ });
213
+ });
214
+
215
+ describe('verify', () => {
216
+ async function getToOtpStep(user: ReturnType<typeof userEvent.setup>) {
217
+ mockInitiateHandover.mockResolvedValueOnce({ request_id: 'req-123' });
218
+ await resolveDoctorAndContinue(user);
219
+ await user.click(screen.getByRole('button', { name: /send otp/i }));
220
+ await screen.findByText(/enter doctor otp/i);
221
+ }
222
+
223
+ it('on success: verifies with incidenceNumber/request_id/otp and completes the handover', async () => {
224
+ const user = userEvent.setup();
225
+ const props = baseProps();
226
+ const { container } = render(<HandoverModal {...props} />);
227
+ await getToOtpStep(user);
228
+
229
+ typeOtp(container, '654321');
230
+ await waitFor(() => expect(screen.getByRole('button', { name: /verify/i })).toBeEnabled());
231
+ mockVerifyHandoverOtp.mockResolvedValueOnce({ status: 'verified' });
232
+ await user.click(screen.getByRole('button', { name: /verify/i }));
233
+
234
+ await waitFor(() =>
235
+ expect(mockVerifyHandoverOtp).toHaveBeenCalledWith({
236
+ incidenceNumber: 'AMB-d22419d8-FAC',
237
+ request_id: 'req-123',
238
+ otp: '654321',
239
+ locationUuid: 'location-uuid-1',
240
+ }),
241
+ );
242
+ expect(props.onHandoverComplete).toHaveBeenCalledWith(referral);
243
+ });
244
+
245
+ it('on an invalid/expired OTP: keeps the modal open on the OTP step with a specific message', async () => {
246
+ const user = userEvent.setup();
247
+ const props = baseProps();
248
+ const { container } = render(<HandoverModal {...props} />);
249
+ await getToOtpStep(user);
250
+
251
+ typeOtp(container, '000000');
252
+ mockVerifyHandoverOtp.mockRejectedValueOnce(new EmtApiError(410, 'Request failed with 410'));
253
+ await user.click(screen.getByRole('button', { name: /verify/i }));
254
+
255
+ expect(await screen.findByText(/otp has expired/i)).toBeInTheDocument();
256
+ expect(screen.getByText(/enter doctor otp/i)).toBeInTheDocument();
257
+ expect(props.onHandoverComplete).not.toHaveBeenCalled();
258
+ });
259
+
260
+ it('on 404 (already handled elsewhere) during verify: notifies the parent, does not complete the handover', async () => {
261
+ const user = userEvent.setup();
262
+ const props = baseProps();
263
+ const { container } = render(<HandoverModal {...props} />);
264
+ await getToOtpStep(user);
265
+
266
+ typeOtp(container, '654321');
267
+ mockVerifyHandoverOtp.mockRejectedValueOnce(new EmtApiError(404, 'Request failed with 404'));
268
+ await user.click(screen.getByRole('button', { name: /verify/i }));
269
+
270
+ await waitFor(() => expect(props.onReferralUnavailable).toHaveBeenCalledWith(referral, expect.stringMatching(/handled elsewhere/i)));
271
+ expect(props.onHandoverComplete).not.toHaveBeenCalled();
272
+ });
273
+ });