@tellescope/sdk 1.250.1 → 1.251.0

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 (96) hide show
  1. package/lib/cjs/sdk.d.ts +9 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +3 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
  7. package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js +28 -15
  10. package/lib/cjs/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
  12. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  13. package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
  14. package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
  15. package/lib/cjs/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
  16. package/lib/cjs/tests/api_tests/medication_added_trigger.test.js +556 -105
  17. package/lib/cjs/tests/api_tests/medication_added_trigger.test.js.map +1 -1
  18. package/lib/cjs/tests/api_tests/outbound_chat_sent_trigger.test.d.ts +7 -0
  19. package/lib/cjs/tests/api_tests/outbound_chat_sent_trigger.test.d.ts.map +1 -0
  20. package/lib/cjs/tests/api_tests/outbound_chat_sent_trigger.test.js +436 -0
  21. package/lib/cjs/tests/api_tests/outbound_chat_sent_trigger.test.js.map +1 -0
  22. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  23. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  24. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +370 -0
  25. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  26. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  27. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  28. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
  29. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  30. package/lib/cjs/tests/setup.d.ts.map +1 -1
  31. package/lib/cjs/tests/setup.js +47 -32
  32. package/lib/cjs/tests/setup.js.map +1 -1
  33. package/lib/cjs/tests/tests.d.ts.map +1 -1
  34. package/lib/cjs/tests/tests.js +190 -161
  35. package/lib/cjs/tests/tests.js.map +1 -1
  36. package/lib/cjs/tests/unit_tests/conditional_logic_medication.test.d.ts +3 -0
  37. package/lib/cjs/tests/unit_tests/conditional_logic_medication.test.d.ts.map +1 -0
  38. package/lib/cjs/tests/unit_tests/conditional_logic_medication.test.js +114 -0
  39. package/lib/cjs/tests/unit_tests/conditional_logic_medication.test.js.map +1 -0
  40. package/lib/esm/sdk.d.ts +9 -0
  41. package/lib/esm/sdk.d.ts.map +1 -1
  42. package/lib/esm/sdk.js +3 -0
  43. package/lib/esm/sdk.js.map +1 -1
  44. package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  45. package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
  46. package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
  47. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.d.ts.map +1 -1
  48. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js +28 -15
  49. package/lib/esm/tests/api_tests/enduser_cross_access_isolation.test.js.map +1 -1
  50. package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
  51. package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  52. package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
  53. package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
  54. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
  55. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
  57. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
  58. package/lib/esm/tests/api_tests/medication_added_trigger.test.d.ts.map +1 -1
  59. package/lib/esm/tests/api_tests/medication_added_trigger.test.js +556 -105
  60. package/lib/esm/tests/api_tests/medication_added_trigger.test.js.map +1 -1
  61. package/lib/esm/tests/api_tests/outbound_chat_sent_trigger.test.d.ts +7 -0
  62. package/lib/esm/tests/api_tests/outbound_chat_sent_trigger.test.d.ts.map +1 -0
  63. package/lib/esm/tests/api_tests/outbound_chat_sent_trigger.test.js +432 -0
  64. package/lib/esm/tests/api_tests/outbound_chat_sent_trigger.test.js.map +1 -0
  65. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  66. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +366 -0
  68. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  70. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
  72. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  73. package/lib/esm/tests/setup.d.ts.map +1 -1
  74. package/lib/esm/tests/setup.js +47 -32
  75. package/lib/esm/tests/setup.js.map +1 -1
  76. package/lib/esm/tests/tests.d.ts.map +1 -1
  77. package/lib/esm/tests/tests.js +190 -161
  78. package/lib/esm/tests/tests.js.map +1 -1
  79. package/lib/esm/tests/unit_tests/conditional_logic_medication.test.d.ts +3 -0
  80. package/lib/esm/tests/unit_tests/conditional_logic_medication.test.d.ts.map +1 -0
  81. package/lib/esm/tests/unit_tests/conditional_logic_medication.test.js +111 -0
  82. package/lib/esm/tests/unit_tests/conditional_logic_medication.test.js.map +1 -0
  83. package/lib/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +10 -10
  85. package/src/sdk.ts +12 -0
  86. package/src/tests/api_tests/account_switcher.test.ts +1283 -0
  87. package/src/tests/api_tests/enduser_cross_access_isolation.test.ts +26 -0
  88. package/src/tests/api_tests/enduser_login.test.ts +215 -0
  89. package/src/tests/api_tests/medication_added_trigger.test.ts +345 -4
  90. package/src/tests/api_tests/outbound_chat_sent_trigger.test.ts +339 -0
  91. package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +198 -0
  92. package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
  93. package/src/tests/setup.ts +8 -1
  94. package/src/tests/tests.ts +23 -6
  95. package/src/tests/unit_tests/conditional_logic_medication.test.ts +133 -0
  96. package/test_generated.pdf +0 -0
@@ -339,6 +339,32 @@ export const enduser_cross_access_isolation_tests = async (
339
339
  await sdkA.authenticate(enduserA.email!, password)
340
340
  await sdkB.authenticate(enduserB.email!, password)
341
341
 
342
+ // Regression guard: hashedPassword must never appear in updateOne responses,
343
+ // for either enduser self-update or admin-side update of an enduser.
344
+ const selfUpdateResponse = await sdkA.api.endusers.updateOne(enduserA.id, { fname: 'RedactionProbe' })
345
+ assert(
346
+ !!selfUpdateResponse && (selfUpdateResponse as any).fname === 'RedactionProbe',
347
+ 'enduser self-update: response should reflect the applied update',
348
+ 'enduser self-update: update applied (sanity)',
349
+ )
350
+ assert(
351
+ (selfUpdateResponse as any).hashedPassword === undefined,
352
+ 'enduser self-update: hashedPassword leaked in response',
353
+ 'enduser self-update: hashedPassword redacted from response',
354
+ )
355
+
356
+ const adminUpdateResponse = await sdk.api.endusers.updateOne(enduserA.id, { fname: 'AdminProbe' })
357
+ assert(
358
+ !!adminUpdateResponse && (adminUpdateResponse as any).fname === 'AdminProbe',
359
+ 'admin update of enduser: response should reflect the applied update',
360
+ 'admin update of enduser: update applied (sanity)',
361
+ )
362
+ assert(
363
+ (adminUpdateResponse as any).hashedPassword === undefined,
364
+ 'admin update of enduser: hashedPassword leaked in response',
365
+ 'admin update of enduser: hashedPassword redacted from response',
366
+ )
367
+
342
368
  for (const c of MODEL_CASES) {
343
369
  const sublog = (variant: string) => `${c.model}: ${variant}`
344
370
 
@@ -0,0 +1,215 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Coverage for the /v1/login-enduser response surface:
14
+ // - When an enduser has no password set, the error response must not include
15
+ // enduser fields (PHI, hashedPassword, etc.).
16
+ // - The "enduser not found" and "wrong password" cases must be indistinguishable
17
+ // (same status code, same message) to prevent account enumeration.
18
+ // - verify_otp invalid-code error must not include enduser fields.
19
+
20
+ const post_login = async (body: any) => {
21
+ try {
22
+ const res = await axios.post(`${host}/v1/login-enduser`, body, { validateStatus: () => true })
23
+ return { status: res.status, data: res.data }
24
+ } catch (err: any) {
25
+ return { status: err?.response?.status, data: err?.response?.data }
26
+ }
27
+ }
28
+
29
+ export const enduser_login_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
30
+ log_header("Enduser Login Tests")
31
+
32
+ const ts = Date.now()
33
+ // Distinctive markers we can grep the response body for.
34
+ const MARKER_FNAME = `LoginTestFname${ts}`
35
+ const MARKER_ADDRESS = `${ts} Test Way`
36
+ const MARKER_DOB = '01-01-1990'
37
+
38
+ const noPasswordEnduser = await sdk.api.endusers.createOne({
39
+ fname: MARKER_FNAME,
40
+ lname: 'LoginTest',
41
+ email: `login-test-no-password-${ts}@tellescope.com`,
42
+ dateOfBirth: MARKER_DOB,
43
+ addressLineOne: MARKER_ADDRESS,
44
+ addressLineTwo: 'Apt 4B',
45
+ city: 'Springfield',
46
+ state: 'IL',
47
+ zipCode: '62701',
48
+ gender: 'Female',
49
+ assignedTo: [sdk.userInfo.id],
50
+ fields: { secretField: `should-not-leak-${ts}` },
51
+ tags: ['vip', 'sensitive'],
52
+ })
53
+
54
+ const withPasswordEnduser = await sdk.api.endusers.createOne({
55
+ fname: 'PasswordedEnduser',
56
+ lname: 'LoginTest',
57
+ email: `login-test-with-password-${ts}@tellescope.com`,
58
+ })
59
+ await sdk.api.endusers.set_password({ id: withPasswordEnduser.id, password: 'CorrectPassword123!' })
60
+
61
+ try {
62
+ // Login response for an enduser without a password set must not include
63
+ // enduser fields.
64
+ const noPasswordResp = await post_login({
65
+ email: noPasswordEnduser.email,
66
+ password: 'arbitrary-password',
67
+ businessId: sdk.userInfo.businessId,
68
+ })
69
+
70
+ const noPasswordBody = JSON.stringify(noPasswordResp.data ?? {})
71
+
72
+ await async_test(
73
+ 'No-password login response does not include fname marker',
74
+ async () => noPasswordBody.includes(MARKER_FNAME) ? 'leaked' : 'safe',
75
+ { expectedResult: 'safe' }
76
+ )
77
+ await async_test(
78
+ 'No-password login response does not include dateOfBirth',
79
+ async () => noPasswordBody.includes(MARKER_DOB) ? 'leaked' : 'safe',
80
+ { expectedResult: 'safe' }
81
+ )
82
+ await async_test(
83
+ 'No-password login response does not include addressLineOne marker',
84
+ async () => noPasswordBody.includes(MARKER_ADDRESS) ? 'leaked' : 'safe',
85
+ { expectedResult: 'safe' }
86
+ )
87
+ for (const sensitiveKey of ['hashedPassword', 'assignedTo', 'fields', 'tags', 'insurance', 'customFields']) {
88
+ await async_test(
89
+ `No-password login response does not include "${sensitiveKey}" key`,
90
+ async () => noPasswordBody.includes(`"${sensitiveKey}"`) ? 'leaked' : 'safe',
91
+ { expectedResult: 'safe' }
92
+ )
93
+ }
94
+ await async_test(
95
+ 'No-password login response info field is absent or empty',
96
+ async () => {
97
+ const info = (noPasswordResp.data ?? {}).info
98
+ if (info === undefined) return 'safe'
99
+ if (typeof info === 'object' && info !== null && Object.keys(info).length === 0) return 'safe'
100
+ return 'leaked'
101
+ },
102
+ { expectedResult: 'safe' }
103
+ )
104
+
105
+ // All three failure modes (no-password-set, wrong-password, unknown-email)
106
+ // must return an identical 401 + identical message — no enumeration of
107
+ // which case actually applies.
108
+ const wrongPasswordResp = await post_login({
109
+ email: withPasswordEnduser.email,
110
+ password: 'WrongPassword!2025',
111
+ businessId: sdk.userInfo.businessId,
112
+ })
113
+ const unknownEmailResp = await post_login({
114
+ email: `does-not-exist-${ts}@tellescope.com`,
115
+ password: 'AnyPassword!2025',
116
+ businessId: sdk.userInfo.businessId,
117
+ })
118
+
119
+ await async_test(
120
+ 'Login returns 401 for no-password account (indistinguishable from wrong password)',
121
+ async () => `${noPasswordResp.status}:${noPasswordResp.data?.message ?? ''}`,
122
+ { expectedResult: '401:Login details are invalid' }
123
+ )
124
+ await async_test(
125
+ 'Login returns same status for no-password vs wrong-password vs unknown-email',
126
+ async () => {
127
+ const statuses = [noPasswordResp.status, wrongPasswordResp.status, unknownEmailResp.status]
128
+ const allSame = statuses.every(s => s === statuses[0])
129
+ return allSame ? `same:${statuses[0]}` : `diff:${statuses.join(',')}`
130
+ },
131
+ { expectedResult: 'same:401' }
132
+ )
133
+ await async_test(
134
+ 'Login returns same message for no-password vs wrong-password vs unknown-email',
135
+ async () => {
136
+ const messages = [noPasswordResp.data?.message, wrongPasswordResp.data?.message, unknownEmailResp.data?.message]
137
+ const allSame = messages.every(m => m === messages[0])
138
+ return allSame ? 'same' : `diff:${JSON.stringify(messages)}`
139
+ },
140
+ { expectedResult: 'same' }
141
+ )
142
+
143
+ // verify_otp invalid-code response must not include enduser fields.
144
+ const verifyOtpInvalidResp = await axios.post(
145
+ `${host}/v1/verify-otp-code`,
146
+ { token: 'not-a-real-token', code: '000000', businessId: sdk.userInfo.businessId },
147
+ { validateStatus: () => true }
148
+ )
149
+ const verifyOtpBody = JSON.stringify(verifyOtpInvalidResp.data ?? {})
150
+ await async_test(
151
+ 'verify_otp invalid-code response does not include any enduser fields',
152
+ async () => (
153
+ verifyOtpBody.includes('"hashedPassword"')
154
+ || verifyOtpBody.includes('"assignedTo"')
155
+ || verifyOtpBody.includes(MARKER_FNAME)
156
+ ? 'leaked' : 'safe'
157
+ ),
158
+ { expectedResult: 'safe' }
159
+ )
160
+
161
+ // Regression: admin creating a duplicate enduser (same email) must still
162
+ // receive the existing record's id in the error info — this is an
163
+ // authenticated, intentional API-aid pattern via uniquenessError and must
164
+ // not be regressed by the public-endpoint sanitizer.
165
+ let duplicateError: any = null
166
+ try {
167
+ await sdk.api.endusers.createOne({ email: noPasswordEnduser.email })
168
+ } catch (err: any) {
169
+ duplicateError = err
170
+ }
171
+ await async_test(
172
+ 'Admin duplicate enduser create rejects with 409 Uniqueness Violation',
173
+ async () => duplicateError?.message ?? 'no-error',
174
+ { expectedResult: 'Uniqueness Violation' }
175
+ )
176
+ await async_test(
177
+ 'Admin duplicate enduser create returns the existing record id in info',
178
+ async () => {
179
+ const info = duplicateError?.info
180
+ if (!Array.isArray(info) || info.length === 0) return `no-info:${JSON.stringify(info)}`
181
+ const existing = info[0]?.existingRecord
182
+ const existingId = existing?._id ?? existing?.id
183
+ return existingId === noPasswordEnduser.id ? 'matched' : `mismatched:${existingId}`
184
+ },
185
+ { expectedResult: 'matched' }
186
+ )
187
+ } finally {
188
+ await Promise.all([
189
+ sdk.api.endusers.deleteOne(noPasswordEnduser.id).catch(() => null),
190
+ sdk.api.endusers.deleteOne(withPasswordEnduser.id).catch(() => null),
191
+ ])
192
+ }
193
+ }
194
+
195
+ // Allow running this test file independently
196
+ if (require.main === module) {
197
+ console.log(`🌐 Using API URL: ${host}`)
198
+ const sdk = new Session({ host })
199
+ const sdkNonAdmin = new Session({ host })
200
+
201
+ const runTests = async () => {
202
+ await setup_tests(sdk, sdkNonAdmin)
203
+ await enduser_login_tests({ sdk, sdkNonAdmin })
204
+ }
205
+
206
+ runTests()
207
+ .then(() => {
208
+ console.log("✅ Enduser login test suite completed successfully")
209
+ process.exit(0)
210
+ })
211
+ .catch((error) => {
212
+ console.error("❌ Enduser login test suite failed:", error)
213
+ process.exit(1)
214
+ })
215
+ }
@@ -182,7 +182,7 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
182
182
  title: "Medication - Beluga Protocol Test"
183
183
  })
184
184
 
185
- // Simulate the Beluga RX_WRITTEN webhook
185
+ // Simulate the Beluga RX_WRITTEN webhook with two meds covering category present (typical) and "N/A"
186
186
  const webhookResponse = await fetch(`${host}/v1/webhooks/beluga`, {
187
187
  method: 'POST',
188
188
  headers: { 'Content-Type': 'application/json' },
@@ -197,6 +197,15 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
197
197
  quantity: '1',
198
198
  medId: 'test-ndc-123',
199
199
  rxId: 'test-rx-456',
200
+ category: 'weightloss1',
201
+ }, {
202
+ name: 'Metformin',
203
+ strength: '500mg',
204
+ refills: '2',
205
+ quantity: '1',
206
+ medId: 'test-ndc-789',
207
+ rxId: 'test-rx-789',
208
+ category: 'N/A',
200
209
  }],
201
210
  }),
202
211
  })
@@ -216,18 +225,83 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
216
225
 
217
226
  // Verify the medication was created with correct protocol and source
218
227
  const belugaMeds = await sdk.api.enduser_medications.getSome({ filter: { enduserId: belugaEnduser.id } })
219
- const belugaMed = belugaMeds[0]
228
+ const belugaMedSemaglutide = belugaMeds.find(m => m.title === 'Semaglutide')
229
+ const belugaMedMetformin = belugaMeds.find(m => m.title === 'Metformin')
230
+ if (!belugaMedSemaglutide || !belugaMedMetformin) {
231
+ throw new Error(`Beluga RX_WRITTEN - expected medications missing. Got: ${belugaMeds.map(m => m.title).join(', ')}`)
232
+ }
220
233
  await async_test(
221
234
  "Beluga RX_WRITTEN - Medication has protocol from form belugaVisitType",
222
- async () => belugaMed,
223
- { onResult: (m: EnduserMedication) => m.protocol === 'Weight Loss' && m.source === 'Beluga' && m.title === 'Semaglutide' }
235
+ async () => belugaMedSemaglutide,
236
+ { onResult: (m: EnduserMedication) => m?.protocol === 'Weight Loss' && m?.source === 'Beluga' && m?.title === 'Semaglutide' }
237
+ )
238
+ await async_test(
239
+ "Beluga RX_WRITTEN - Medication has category from webhook",
240
+ async () => belugaMedSemaglutide,
241
+ { onResult: (m: EnduserMedication) => m?.category === 'weightloss1' }
242
+ )
243
+ await async_test(
244
+ "Beluga RX_WRITTEN - Medication preserves N/A category verbatim",
245
+ async () => belugaMedMetformin,
246
+ { onResult: (m: EnduserMedication) => m?.category === 'N/A' }
247
+ )
248
+
249
+ // Backwards-compatibility: a webhook with no category on any med should result in undefined category
250
+ const belugaFormNoCategory = await sdk.api.forms.createOne({
251
+ title: 'Beluga Protocol Test Form (no category)',
252
+ belugaVisitType: 'Weight Loss',
253
+ })
254
+ const belugaEnduserNoCategory = await sdk.api.endusers.createOne({})
255
+ const belugaFormResponseNoCategory = await sdk.api.form_responses.createOne({
256
+ formId: belugaFormNoCategory.id,
257
+ enduserId: belugaEnduserNoCategory.id,
258
+ formTitle: belugaFormNoCategory.title,
259
+ responses: [{
260
+ fieldId: PLACEHOLDER_ID,
261
+ fieldTitle: 'placeholder',
262
+ answer: { type: 'string', value: 'test' },
263
+ }],
264
+ })
265
+
266
+ const webhookResponseNoCategory = await fetch(`${host}/v1/webhooks/beluga`, {
267
+ method: 'POST',
268
+ headers: { 'Content-Type': 'application/json' },
269
+ body: JSON.stringify({
270
+ masterId: `tellescope_${belugaFormResponseNoCategory.id}`,
271
+ event: 'RX_WRITTEN',
272
+ docName: 'Dr. Test',
273
+ medsPrescribed: [{
274
+ name: 'Ibuprofen',
275
+ strength: '200mg',
276
+ refills: '1',
277
+ quantity: '1',
278
+ medId: 'test-ndc-nocat',
279
+ rxId: 'test-rx-nocat',
280
+ }],
281
+ }),
282
+ })
283
+
284
+ if (!webhookResponseNoCategory.ok) {
285
+ throw new Error(`Beluga webhook (no category) failed with status ${webhookResponseNoCategory.status}: ${await webhookResponseNoCategory.text()}`)
286
+ }
287
+
288
+ await wait(undefined, 500)
289
+
290
+ const belugaMedsNoCategory = await sdk.api.enduser_medications.getSome({ filter: { enduserId: belugaEnduserNoCategory.id } })
291
+ const belugaMedNoCategory = belugaMedsNoCategory[0]
292
+ await async_test(
293
+ "Beluga RX_WRITTEN - Medication omits category when not provided",
294
+ async () => belugaMedNoCategory,
295
+ { onResult: (m: EnduserMedication) => m?.category === undefined || m?.category === null }
224
296
  )
225
297
 
226
298
  try {
227
299
  // Clean up Beluga test data
228
300
  await sdk.api.automation_triggers.deleteOne(belugaTrigger.id)
229
301
  await sdk.api.forms.deleteOne(belugaForm.id)
302
+ await sdk.api.forms.deleteOne(belugaFormNoCategory.id)
230
303
  await sdk.api.endusers.deleteOne(belugaEnduser.id) // also cleans up form response and medications
304
+ await sdk.api.endusers.deleteOne(belugaEnduserNoCategory.id) // also cleans up form response and medications
231
305
  } finally {}
232
306
 
233
307
  // ---- Set Fields with {{medication.name}} Test ----
@@ -276,11 +350,278 @@ export const medication_added_trigger_tests = async ({ sdk, sdkNonAdmin } : { sd
276
350
  { onResult: (e: Enduser) => e.fields?.['Specific Med'] === 'Metformin 500mg' }
277
351
  )
278
352
 
353
+ // ---- Set Fields with {{medication.category}} Test ----
354
+ log_header("Medication Added - Set Fields with {{medication.category}}")
355
+
356
+ const setFieldsCategoryTrigger = await sdk.api.automation_triggers.createOne({
357
+ event: { type: 'Medication Added', info: { titles: [], protocols: [] } },
358
+ action: { type: 'Set Fields', info: { fields: [{ name: 'Medication Category', type: 'Custom Value' as const, value: '{{medication.category}}' }] }},
359
+ status: 'Active',
360
+ title: "Medication - Set Fields medication.category"
361
+ })
362
+
363
+ const setFieldsCategoryEnduser = await sdk.api.endusers.createOne({})
364
+
365
+ const setFieldsCategoryMed = await sdk.api.enduser_medications.createOne({
366
+ enduserId: setFieldsCategoryEnduser.id,
367
+ title: 'Ozempic 0.5mg',
368
+ category: 'weightloss1',
369
+ })
370
+ await wait(undefined, 500)
371
+
372
+ await async_test(
373
+ "Medication Added - Set Fields copies medication.category to enduser field",
374
+ () => sdk.api.endusers.getOne(setFieldsCategoryEnduser.id),
375
+ { onResult: (e: Enduser) => e.fields?.['Medication Category'] === 'weightloss1' }
376
+ )
377
+
378
+ // Category absent on medication → placeholder resolves to empty string
379
+ const setFieldsCategoryEnduserEmpty = await sdk.api.endusers.createOne({})
380
+ const setFieldsCategoryMedEmpty = await sdk.api.enduser_medications.createOne({
381
+ enduserId: setFieldsCategoryEnduserEmpty.id,
382
+ title: 'Lisinopril 20mg',
383
+ })
384
+ await wait(undefined, 500)
385
+
386
+ await async_test(
387
+ "Medication Added - Set Fields with no category resolves to empty string",
388
+ () => sdk.api.endusers.getOne(setFieldsCategoryEnduserEmpty.id),
389
+ { onResult: (e: Enduser) => e.fields?.['Medication Category'] === '' }
390
+ )
391
+
279
392
  try {
280
393
  await sdk.api.automation_triggers.deleteOne(setFieldsTrigger.id)
281
394
  await sdk.api.automation_triggers.deleteOne(setFieldsTriggerFiltered.id)
395
+ await sdk.api.automation_triggers.deleteOne(setFieldsCategoryTrigger.id)
282
396
  await sdk.api.endusers.deleteOne(setFieldsEnduser.id)
283
397
  await sdk.api.endusers.deleteOne(setFieldsEnduser2.id)
398
+ await sdk.api.endusers.deleteOne(setFieldsCategoryEnduser.id)
399
+ await sdk.api.endusers.deleteOne(setFieldsCategoryEnduserEmpty.id)
400
+ } finally {}
401
+
402
+ // ---- titleCondition (compound conditional logic on title) ----
403
+ log_header("Medication Added - titleCondition compound logic")
404
+
405
+ // C1: simple $contains — fires for medications whose title contains 'GLP' (case-sensitive)
406
+ const c1Trigger = await sdk.api.automation_triggers.createOne({
407
+ event: {
408
+ type: 'Medication Added',
409
+ info: {
410
+ titles: [],
411
+ protocols: [],
412
+ titleCondition: { condition: { title: { $contains: 'GLP' } } },
413
+ },
414
+ },
415
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-Contains-GLP'] }},
416
+ status: 'Active',
417
+ title: "Medication - titleCondition contains GLP"
418
+ })
419
+
420
+ const c1MatchEnduser = await sdk.api.endusers.createOne({})
421
+ await sdk.api.enduser_medications.createOne({ enduserId: c1MatchEnduser.id, title: 'Semaglutide GLP-1' })
422
+ await wait(undefined, 500)
423
+ await async_test(
424
+ "titleCondition $contains - matching medication fires",
425
+ () => sdk.api.endusers.getOne(c1MatchEnduser.id),
426
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-Contains-GLP') }
427
+ )
428
+
429
+ const c1MissEnduser = await sdk.api.endusers.createOne({})
430
+ await sdk.api.enduser_medications.createOne({ enduserId: c1MissEnduser.id, title: 'Aspirin' })
431
+ await wait(undefined, 500)
432
+ await async_test(
433
+ "titleCondition $contains - non-matching medication does NOT fire",
434
+ () => sdk.api.endusers.getOne(c1MissEnduser.id),
435
+ { onResult: (e: Enduser) => !e.tags?.includes('Med-Cond-Contains-GLP') }
436
+ )
437
+
438
+ // C2: $ne — fires for everything except 'Placebo'
439
+ const c2Trigger = await sdk.api.automation_triggers.createOne({
440
+ event: {
441
+ type: 'Medication Added',
442
+ info: {
443
+ titles: [],
444
+ protocols: [],
445
+ titleCondition: { condition: { title: { $ne: 'Placebo' } } },
446
+ },
447
+ },
448
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-Ne-Placebo'] }},
449
+ status: 'Active',
450
+ title: "Medication - titleCondition ne Placebo"
451
+ })
452
+
453
+ const c2MatchEnduser = await sdk.api.endusers.createOne({})
454
+ await sdk.api.enduser_medications.createOne({ enduserId: c2MatchEnduser.id, title: 'Metformin' })
455
+ await wait(undefined, 500)
456
+ await async_test(
457
+ "titleCondition $ne - non-Placebo medication fires",
458
+ () => sdk.api.endusers.getOne(c2MatchEnduser.id),
459
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-Ne-Placebo') }
460
+ )
461
+
462
+ const c2MissEnduser = await sdk.api.endusers.createOne({})
463
+ await sdk.api.enduser_medications.createOne({ enduserId: c2MissEnduser.id, title: 'Placebo' })
464
+ await wait(undefined, 500)
465
+ await async_test(
466
+ "titleCondition $ne - Placebo medication does NOT fire",
467
+ () => sdk.api.endusers.getOne(c2MissEnduser.id),
468
+ { onResult: (e: Enduser) => !e.tags?.includes('Med-Cond-Ne-Placebo') }
469
+ )
470
+
471
+ // C3: compound $and — contains 'mg' AND does not contain 'Placebo' (case-sensitive)
472
+ const c3Trigger = await sdk.api.automation_triggers.createOne({
473
+ event: {
474
+ type: 'Medication Added',
475
+ info: {
476
+ titles: [],
477
+ protocols: [],
478
+ titleCondition: {
479
+ $and: [
480
+ { condition: { title: { $contains: 'mg' } } },
481
+ { condition: { title: { $doesNotContain: 'Placebo' } } },
482
+ ],
483
+ },
484
+ },
485
+ },
486
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-And'] }},
487
+ status: 'Active',
488
+ title: "Medication - titleCondition compound AND"
489
+ })
490
+
491
+ const c3MatchEnduser = await sdk.api.endusers.createOne({})
492
+ await sdk.api.enduser_medications.createOne({ enduserId: c3MatchEnduser.id, title: 'Lisinopril 10mg' })
493
+ await wait(undefined, 500)
494
+ await async_test(
495
+ "titleCondition $and - both pass fires",
496
+ () => sdk.api.endusers.getOne(c3MatchEnduser.id),
497
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-And') }
498
+ )
499
+
500
+ const c3MissEnduser = await sdk.api.endusers.createOne({})
501
+ await sdk.api.enduser_medications.createOne({ enduserId: c3MissEnduser.id, title: 'Placebo 5mg' })
502
+ await wait(undefined, 500)
503
+ await async_test(
504
+ "titleCondition $and - second branch fails does NOT fire",
505
+ () => sdk.api.endusers.getOne(c3MissEnduser.id),
506
+ { onResult: (e: Enduser) => !e.tags?.includes('Med-Cond-And') }
507
+ )
508
+
509
+ // C4: compound $or — equals 'Aspirin' OR contains 'pril'
510
+ const c4Trigger = await sdk.api.automation_triggers.createOne({
511
+ event: {
512
+ type: 'Medication Added',
513
+ info: {
514
+ titles: [],
515
+ protocols: [],
516
+ titleCondition: {
517
+ $or: [
518
+ { condition: { title: 'Aspirin' } },
519
+ { condition: { title: { $contains: 'pril' } } },
520
+ ],
521
+ },
522
+ },
523
+ },
524
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-Or'] }},
525
+ status: 'Active',
526
+ title: "Medication - titleCondition compound OR"
527
+ })
528
+
529
+ const c4MatchA = await sdk.api.endusers.createOne({})
530
+ await sdk.api.enduser_medications.createOne({ enduserId: c4MatchA.id, title: 'Aspirin' })
531
+ await wait(undefined, 500)
532
+ await async_test(
533
+ "titleCondition $or - first branch matches fires",
534
+ () => sdk.api.endusers.getOne(c4MatchA.id),
535
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-Or') }
536
+ )
537
+
538
+ const c4MatchB = await sdk.api.endusers.createOne({})
539
+ await sdk.api.enduser_medications.createOne({ enduserId: c4MatchB.id, title: 'Lisinopril' })
540
+ await wait(undefined, 500)
541
+ await async_test(
542
+ "titleCondition $or - second branch matches fires",
543
+ () => sdk.api.endusers.getOne(c4MatchB.id),
544
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-Or') }
545
+ )
546
+
547
+ const c4Miss = await sdk.api.endusers.createOne({})
548
+ await sdk.api.enduser_medications.createOne({ enduserId: c4Miss.id, title: 'Metformin' })
549
+ await wait(undefined, 500)
550
+ await async_test(
551
+ "titleCondition $or - neither branch matches does NOT fire",
552
+ () => sdk.api.endusers.getOne(c4Miss.id),
553
+ { onResult: (e: Enduser) => !e.tags?.includes('Med-Cond-Or') }
554
+ )
555
+
556
+ // C5: backwards compatibility — no titleCondition, titles array still works
557
+ const c5Trigger = await sdk.api.automation_triggers.createOne({
558
+ event: { type: 'Medication Added', info: { titles: ['Atorvastatin'], protocols: [] } },
559
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-BackCompat'] }},
560
+ status: 'Active',
561
+ title: "Medication - titles array back-compat"
562
+ })
563
+
564
+ const c5Match = await sdk.api.endusers.createOne({})
565
+ await sdk.api.enduser_medications.createOne({ enduserId: c5Match.id, title: 'Atorvastatin' })
566
+ await wait(undefined, 500)
567
+ await async_test(
568
+ "titles back-compat - title match still fires without titleCondition",
569
+ () => sdk.api.endusers.getOne(c5Match.id),
570
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-BackCompat') }
571
+ )
572
+
573
+ const c5Miss = await sdk.api.endusers.createOne({})
574
+ await sdk.api.enduser_medications.createOne({ enduserId: c5Miss.id, title: 'Aspirin' })
575
+ await wait(undefined, 500)
576
+ await async_test(
577
+ "titles back-compat - non-match does NOT fire without titleCondition",
578
+ () => sdk.api.endusers.getOne(c5Miss.id),
579
+ { onResult: (e: Enduser) => !e.tags?.includes('Med-Cond-BackCompat') }
580
+ )
581
+
582
+ // C6: combined — titles array AND titleCondition compose (both must pass)
583
+ const c6Trigger = await sdk.api.automation_triggers.createOne({
584
+ event: {
585
+ type: 'Medication Added',
586
+ info: {
587
+ titles: ['Lisinopril'],
588
+ protocols: [],
589
+ titleCondition: { condition: { title: { $contains: 'Lisin' } } },
590
+ },
591
+ },
592
+ action: { type: 'Add Tags', info: { tags: ['Med-Cond-Combined'] }},
593
+ status: 'Active',
594
+ title: "Medication - titles + titleCondition combined"
595
+ })
596
+
597
+ const c6Match = await sdk.api.endusers.createOne({})
598
+ await sdk.api.enduser_medications.createOne({ enduserId: c6Match.id, title: 'Lisinopril' })
599
+ await wait(undefined, 500)
600
+ await async_test(
601
+ "titleCondition combined - both pass fires",
602
+ () => sdk.api.endusers.getOne(c6Match.id),
603
+ { onResult: (e: Enduser) => !!e.tags?.includes('Med-Cond-Combined') }
604
+ )
605
+
606
+ try {
607
+ await sdk.api.automation_triggers.deleteOne(c1Trigger.id)
608
+ await sdk.api.automation_triggers.deleteOne(c2Trigger.id)
609
+ await sdk.api.automation_triggers.deleteOne(c3Trigger.id)
610
+ await sdk.api.automation_triggers.deleteOne(c4Trigger.id)
611
+ await sdk.api.automation_triggers.deleteOne(c5Trigger.id)
612
+ await sdk.api.automation_triggers.deleteOne(c6Trigger.id)
613
+ await sdk.api.endusers.deleteOne(c1MatchEnduser.id)
614
+ await sdk.api.endusers.deleteOne(c1MissEnduser.id)
615
+ await sdk.api.endusers.deleteOne(c2MatchEnduser.id)
616
+ await sdk.api.endusers.deleteOne(c2MissEnduser.id)
617
+ await sdk.api.endusers.deleteOne(c3MatchEnduser.id)
618
+ await sdk.api.endusers.deleteOne(c3MissEnduser.id)
619
+ await sdk.api.endusers.deleteOne(c4MatchA.id)
620
+ await sdk.api.endusers.deleteOne(c4MatchB.id)
621
+ await sdk.api.endusers.deleteOne(c4Miss.id)
622
+ await sdk.api.endusers.deleteOne(c5Match.id)
623
+ await sdk.api.endusers.deleteOne(c5Miss.id)
624
+ await sdk.api.endusers.deleteOne(c6Match.id)
284
625
  } finally {}
285
626
  }
286
627