@tellescope/sdk 1.255.19 → 1.256.1

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/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
  4. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  7. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  9. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  10. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  11. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  14. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  15. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  16. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  17. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  19. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  21. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  23. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  25. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  27. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  28. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  29. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  30. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  31. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  32. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  34. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  35. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  36. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  37. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
  38. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  39. package/lib/cjs/tests/tests.d.ts.map +1 -1
  40. package/lib/cjs/tests/tests.js +477 -260
  41. package/lib/cjs/tests/tests.js.map +1 -1
  42. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  43. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  44. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
  45. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  46. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  47. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  48. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  49. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  50. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  51. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  52. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  53. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  54. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  55. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  56. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  57. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  58. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  59. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  60. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  61. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  62. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  63. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  64. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  65. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  66. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  67. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  68. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  69. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  70. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  71. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  72. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  73. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  74. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  75. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  76. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  77. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
  79. package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  80. package/lib/esm/tests/tests.d.ts.map +1 -1
  81. package/lib/esm/tests/tests.js +477 -260
  82. package/lib/esm/tests/tests.js.map +1 -1
  83. package/lib/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +10 -10
  85. package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
  86. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  87. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  88. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  89. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  90. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  91. package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
  92. package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
  93. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
  94. package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
  95. package/src/tests/tests.ts +220 -1
  96. package/test_generated.pdf +0 -0
@@ -0,0 +1,256 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import { log_header, wait, async_test, assert } from "@tellescope/testing"
5
+ import { setup_tests } from "../setup"
6
+
7
+ const host = process.env.API_URL || "http://localhost:8080"
8
+
9
+ const TEST_MARKER = '__TELLESCOPE_TEST_INCOMING_SMS_AUTOMATION__'
10
+ const TEST_PHONE = '+15555555555' // send_sms short-circuits real sends for this number
11
+
12
+ // SMS AI conversations (startAIConversation journey action).
13
+ // Runtime flows (start/supersede/takeover/inbound absorption) require the organization to have an
14
+ // SMS number configured — when it doesn't (typical dev org), those sections assert the
15
+ // configuration error path and are otherwise skipped; config validation runs everywhere.
16
+ export const start_ai_conversation_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
17
+ log_header("Start AI Conversation (SMS Agent) Tests")
18
+
19
+ const users = await sdk.api.users.getSome({})
20
+ const senderId = users[0].id
21
+
22
+ const journey = await sdk.api.journeys.createOne({ title: 'SMS Agent Test Journey' })
23
+ const createdEnduserIds: string[] = []
24
+
25
+ try {
26
+ /*************** Config validation round-trip ***************/
27
+ const validInfo = {
28
+ channel: 'SMS' as const,
29
+ senderId,
30
+ initialMessage: 'Hello {{enduser.fname}}! Quick question for you.',
31
+ prompt: 'You are texting on behalf of Test Clinic. Answer briefly.',
32
+ outcomes: [{ value: 'resolved', description: 'The question was answered' }],
33
+ }
34
+
35
+ const step = await sdk.api.automation_steps.createOne({
36
+ journeyId: journey.id,
37
+ events: [{ type: 'onJourneyStart', info: {} }],
38
+ action: { type: 'startAIConversation', info: validInfo },
39
+ })
40
+ assert(
41
+ step.action.type === 'startAIConversation' && step.action.info.initialMessage === validInfo.initialMessage,
42
+ 'startAIConversation step round-trip failed',
43
+ 'startAIConversation step round-trips',
44
+ )
45
+
46
+ const outcomeChild = await sdk.api.automation_steps.createOne({
47
+ journeyId: journey.id,
48
+ events: [{ type: 'onAIConversationOutcome', info: { automationStepId: step.id, outcome: 'resolved' } }],
49
+ action: { type: 'addEnduserTags', info: { tags: ['sms-agent-resolved'] } },
50
+ })
51
+ assert(
52
+ outcomeChild.events[0].type === 'onAIConversationOutcome',
53
+ 'onAIConversationOutcome event round-trip failed',
54
+ 'onAIConversationOutcome event round-trips',
55
+ )
56
+
57
+ await async_test('startAIConversation without prompt is rejected',
58
+ () => sdk.api.automation_steps.createOne({
59
+ journeyId: journey.id,
60
+ events: [{ type: 'onJourneyStart', info: {} }],
61
+ action: { type: 'startAIConversation', info: { ...validInfo, prompt: undefined } as any },
62
+ }),
63
+ { shouldError: true, onError: () => true },
64
+ )
65
+ await async_test('startAIConversation without senderId is rejected',
66
+ () => sdk.api.automation_steps.createOne({
67
+ journeyId: journey.id,
68
+ events: [{ type: 'onJourneyStart', info: {} }],
69
+ action: { type: 'startAIConversation', info: { ...validInfo, senderId: undefined } as any },
70
+ }),
71
+ { shouldError: true, onError: () => true },
72
+ )
73
+ await async_test('startAIConversation without initialMessage is rejected',
74
+ () => sdk.api.automation_steps.createOne({
75
+ journeyId: journey.id,
76
+ events: [{ type: 'onJourneyStart', info: {} }],
77
+ action: { type: 'startAIConversation', info: { ...validInfo, initialMessage: undefined } as any },
78
+ }),
79
+ { shouldError: true, onError: () => true },
80
+ )
81
+ await async_test('startAIConversation with invalid tool is rejected',
82
+ () => sdk.api.automation_steps.createOne({
83
+ journeyId: journey.id,
84
+ events: [{ type: 'onJourneyStart', info: {} }],
85
+ action: { type: 'startAIConversation', info: { ...validInfo, tools: [{ type: 'Submit Form', info: { formId: 'not-an-id' } }] } as any },
86
+ }),
87
+ { shouldError: true, onError: () => true },
88
+ )
89
+
90
+ /*************** Runtime: the process endpoint ***************/
91
+ const processFor = async (enduserId: string) => {
92
+ const automatedAction = await sdk.api.automated_actions.createOne({
93
+ enduserId,
94
+ journeyId: journey.id,
95
+ automationStepId: step.id,
96
+ event: { type: 'onJourneyStart', info: {} },
97
+ action: { type: 'startAIConversation', info: validInfo },
98
+ status: 'active',
99
+ processAfter: Date.now(),
100
+ })
101
+ return sdk.api.automated_actions.process({ automatedActionId: automatedAction.id })
102
+ }
103
+
104
+ // no-phone error path works regardless of org SMS configuration
105
+ const phonelessEnduser = await sdk.api.endusers.createOne({ fname: 'sms-agent-nophone' })
106
+ createdEnduserIds.push(phonelessEnduser.id)
107
+ const noPhoneResult = await processFor(phonelessEnduser.id)
108
+ assert(
109
+ !noPhoneResult.success && !!noPhoneResult.error,
110
+ 'phoneless start should fail with an error',
111
+ 'start action errors for a contact without a phone',
112
+ )
113
+
114
+ // resolve the same organization the action does (last organizationId, else businessId) —
115
+ // getSome({})[0] is an arbitrary sub-organization and can be a different record entirely
116
+ const session = sdk.userInfo as { businessId?: string, organizationIds?: string[] }
117
+ const resolvedOrgId = [...(session.organizationIds ?? [])].pop() || session.businessId
118
+ const organization = (await sdk.api.organizations.getSome({})).find(o => o.id === resolvedOrgId)
119
+ const orgHasNumber = !!(organization as any)?.twilioNumber || !!((organization as any)?.twilioNumbers?.length)
120
+
121
+ if (!orgHasNumber) {
122
+ // still assert the wiring: the action processes and reports a configuration error rather than
123
+ // crashing. AI enablement is checked before the SMS number and short-circuits first, and
124
+ // bedrockAIAllowed/creditCount are super-admin-only (unreadable here), so accept either.
125
+ const enduser = await sdk.api.endusers.createOne({ fname: 'sms-agent-nonumber', phone: TEST_PHONE })
126
+ createdEnduserIds.push(enduser.id)
127
+ const result = await processFor(enduser.id)
128
+ assert(
129
+ !result.success && /no sms number configured|ai is not enabled/i.test(result.error || ''),
130
+ `expected a configuration error, got: ${result.error}`,
131
+ 'start action reports a configuration error (org has no SMS number — runtime flows skipped)',
132
+ )
133
+ return
134
+ }
135
+
136
+ /*************** Runtime flows (org has an SMS number) ***************/
137
+ const enduser = await sdk.api.endusers.createOne({ fname: 'sms-agent-runtime', phone: TEST_PHONE })
138
+ createdEnduserIds.push(enduser.id)
139
+
140
+ const startResult = await processFor(enduser.id)
141
+ assert(startResult.success === true, `start failed: ${startResult.error}`, 'start action succeeds')
142
+ await wait(undefined, 1000)
143
+
144
+ let refreshed = await sdk.api.endusers.getOne(enduser.id)
145
+ const entry = (refreshed.aiConversations ?? []).find(c => c.active)
146
+ assert(!!entry, 'no active aiConversations entry on enduser', 'enduser has an active conversation entry')
147
+ assert(entry?.destination === TEST_PHONE, 'wrong destination on entry', 'conversation entry pairs the contact number')
148
+
149
+ const conversation = await sdk.api.ai_conversations.getOne(entry!.aiConversationId)
150
+ assert(conversation.type === 'sms_agent', 'wrong ai_conversations type', 'ai_conversations doc has type sms_agent')
151
+
152
+ const initialMessages = await sdk.api.sms_messages.getSome({ filter: { enduserId: enduser.id } })
153
+ const outbound = initialMessages.find(m => !m.inbound && m.automationStepId === step.id)
154
+ // exact equality on a merge-field-containing opener guards two regressions: templating must
155
+ // happen in send_sms (raw text on the record), and the agent-reply {{ escaping must NOT be
156
+ // applied to the org-authored initial message (preserveMergeFields)
157
+ assert(
158
+ !!outbound && outbound.message === validInfo.initialMessage,
159
+ 'initial SMS record missing or not raw text',
160
+ 'initial SMS logged with raw (untemplated) text + automationStepId',
161
+ )
162
+
163
+ // supersede: second start on the same pairing replaces the first
164
+ const secondStart = await processFor(enduser.id)
165
+ assert(secondStart.success === true, `second start failed: ${secondStart.error}`, 'duplicate start succeeds (replace)')
166
+ await wait(undefined, 1000)
167
+ refreshed = await sdk.api.endusers.getOne(enduser.id)
168
+ const activeEntries = (refreshed.aiConversations ?? []).filter(c => c.active)
169
+ assert(activeEntries.length === 1, 'expected exactly one active conversation', 'supersede leaves exactly one active conversation')
170
+ assert(
171
+ activeEntries[0].aiConversationId !== entry!.aiConversationId,
172
+ 'active conversation was not replaced',
173
+ 'supersede started a fresh conversation',
174
+ )
175
+
176
+ // inbound absorption via the testing hook: STOP ends the conversation
177
+ await sdk.api.sms_messages.createOne({
178
+ enduserId: enduser.id,
179
+ message: `stop${TEST_MARKER}`, // marker is stripped before the AI branch sees the text
180
+ inbound: true,
181
+ logOnly: true,
182
+ phoneNumber: activeEntries[0].source,
183
+ enduserPhoneNumber: TEST_PHONE,
184
+ } as any)
185
+ await wait(undefined, 2000)
186
+ refreshed = await sdk.api.endusers.getOne(enduser.id)
187
+ const stopped = (refreshed.aiConversations ?? []).find(c => c.aiConversationId === activeEntries[0].aiConversationId)
188
+ assert(
189
+ stopped?.active === false && stopped?.endedReason === 'unsubscribed',
190
+ `STOP did not end the conversation (${JSON.stringify(stopped)})`,
191
+ 'STOP reply ends the conversation',
192
+ )
193
+
194
+ // staff takeover: start again, then a manual (user-session) SMS on the thread ends it
195
+ const takeoverStart = await processFor(enduser.id)
196
+ assert(takeoverStart.success === true, `takeover-start failed: ${takeoverStart.error}`, 'start (for takeover test) succeeds')
197
+ await wait(undefined, 1000)
198
+ await sdk.api.sms_messages.createOne({
199
+ enduserId: enduser.id,
200
+ message: 'Hi, this is a real person taking over.',
201
+ inbound: false,
202
+ phoneNumber: (await sdk.api.endusers.getOne(enduser.id)).aiConversations?.find(c => c.active)?.source,
203
+ enduserPhoneNumber: TEST_PHONE,
204
+ } as any)
205
+ await wait(undefined, 2000)
206
+ refreshed = await sdk.api.endusers.getOne(enduser.id)
207
+ const takenOver = (refreshed.aiConversations ?? []).find(c => c.endedReason === 'staff-takeover')
208
+ assert(!!takenOver, 'no staff-takeover ended entry found', 'manual staff SMS ends the conversation (takeover)')
209
+
210
+ // no-conversation baseline: marker inbound for a fresh enduser produces zero AI artifacts
211
+ const baselineEnduser = await sdk.api.endusers.createOne({ fname: 'sms-agent-baseline', phone: '+15555555554' })
212
+ createdEnduserIds.push(baselineEnduser.id)
213
+ await sdk.api.sms_messages.createOne({
214
+ enduserId: baselineEnduser.id,
215
+ message: `hello there${TEST_MARKER}`,
216
+ inbound: true,
217
+ logOnly: true,
218
+ phoneNumber: activeEntries[0].source,
219
+ enduserPhoneNumber: '+15555555554',
220
+ } as any)
221
+ await wait(undefined, 2000)
222
+ const baselineRefreshed = await sdk.api.endusers.getOne(baselineEnduser.id)
223
+ const baselineMessages = await sdk.api.sms_messages.getSome({ filter: { enduserId: baselineEnduser.id } })
224
+ assert(
225
+ !(baselineRefreshed.aiConversations ?? []).length && !baselineMessages.find(m => !m.inbound),
226
+ 'AI artifacts appeared for an enduser with no conversation',
227
+ 'no-conversation inbound produces zero AI artifacts (regression baseline)',
228
+ )
229
+ } finally {
230
+ await sdk.api.journeys.deleteOne(journey.id).catch(() => {})
231
+ for (const id of createdEnduserIds) {
232
+ await sdk.api.endusers.deleteOne(id).catch(() => {})
233
+ }
234
+ }
235
+ }
236
+
237
+ // Allow running this test file independently
238
+ if (require.main === module) {
239
+ const sdk = new Session({ host })
240
+ const sdkNonAdmin = new Session({ host })
241
+
242
+ const runTests = async () => {
243
+ await setup_tests(sdk, sdkNonAdmin)
244
+ await start_ai_conversation_tests({ sdk, sdkNonAdmin })
245
+ }
246
+
247
+ runTests()
248
+ .then(() => {
249
+ console.log("✅ Start AI Conversation test suite completed successfully")
250
+ process.exit(0)
251
+ })
252
+ .catch((error) => {
253
+ console.error("❌ Start AI Conversation test suite failed:", error)
254
+ process.exit(1)
255
+ })
256
+ }
@@ -17,6 +17,7 @@ import {
17
17
  CompoundFilter,
18
18
  CreateTicketActionInfo,
19
19
  FormResponseValue,
20
+ JourneyContext,
20
21
  ModelName,
21
22
  RoundRobinAssignmentInfo,
22
23
  User,
@@ -41,6 +42,7 @@ import { user_portal_settings_tests } from "./api_tests/user_portal_settings.tes
41
42
  import { integrations_redacted_tests } from "./api_tests/integrations_redacted.test"
42
43
  import { healthie_multi_integration_tests } from "./api_tests/healthie_multi_integration.test"
43
44
  import { get_some_projection_tests } from "./api_tests/get_some_projection.test"
45
+ import { file_download_unicode_names_tests } from "./api_tests/file_download_unicode_names.test"
44
46
  import { mdb_sort_tests } from "./api_tests/mdb_sort.test"
45
47
  import { create_user_notifications_trigger_tests } from "./api_tests/create_user_notifications_trigger.test"
46
48
  import { inbox_thread_assignment_updates_tests } from "./api_tests/inbox_thread_assignment_updates.test"
@@ -110,6 +112,7 @@ import { invite_user_enumeration_tests } from "./api_tests/security/F-0007-invit
110
112
  import { register_business_id_injection_tests } from "./api_tests/security/register_business_id_injection.test";
111
113
  import { handle_incoming_communication_cross_tenant_tests } from "./api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test";
112
114
  import { sanitize_user_html_xss_tests } from "./api_tests/security/F-0013-sanitize-user-html.test";
115
+ import { sanitize_user_html_with_iframes_tests } from "./api_tests/security/F-0013-sanitize-user-html-with-iframes.test";
113
116
  import { prototype_pollution_tests } from "./api_tests/security/F-0016-prototype-pollution.test";
114
117
  import { enduser_cross_create_tests } from "./api_tests/security/F-0116-F-0119-enduser-cross-create.test";
115
118
  import { globalunique_leak_tests } from "./api_tests/security/F-0145-globalunique-leak.test";
@@ -117,6 +120,8 @@ import { load_inbox_redaction_tests } from "./api_tests/security/F-0151-load-inb
117
120
  import { webhook_timeout_tests } from "./api_tests/security/F-0155-webhook-timeout.test";
118
121
  import { enduser_ai_summary_trust_gate_tests } from "./api_tests/security/enduser-ai-summary-trust-gate.test";
119
122
  import { allowed_paths_confinement_tests } from "./api_tests/security/F-0166-allowed-paths-wildcard-bypass.test";
123
+ import { proxy_image_ssrf_tests } from "./api_tests/security/F-0025-proxy-image-unauth-ssrf.test";
124
+ import { webhook_outbound_ssrf_tests } from "./api_tests/security/F-0015-webhook-outbound-ssrf.test";
120
125
  import { public_endpoint_auth_field_lock_tests } from "./api_tests/security/public_endpoint_auth_field_lock.test";
121
126
  import { formsort_webhook_auth_field_lock_tests } from "./api_tests/security/formsort_webhook_auth_field_lock.test";
122
127
  import { bulk_assignment_tests } from "./api_tests/bulk_assignment.test";
@@ -130,6 +135,7 @@ import { load_team_chat_tests } from "./api_tests/load_team_chat.test";
130
135
  import { form_started_trigger_tests } from "./api_tests/form_started_trigger.test";
131
136
  import { formsort_header_auth_tests } from "./api_tests/formsort_header_auth.test";
132
137
  import { form_submitted_trigger_tests } from "./api_tests/form_submitted_trigger.test";
138
+ import { start_ai_conversation_tests } from "./api_tests/start_ai_conversation.test";
133
139
  import { dont_sync_to_elation_form_submission_tests } from "./api_tests/dont_sync_to_elation_form_submission.test";
134
140
  import { medication_added_trigger_tests } from "./api_tests/medication_added_trigger.test";
135
141
  import { conditional_logic_medication_unit_tests } from "./unit_tests/conditional_logic_medication.test";
@@ -141,6 +147,7 @@ import { openloop_webhooks_tests } from "./api_tests/openloop_webhooks.test";
141
147
  import { beluga_pharmacy_mappings_tests } from "./api_tests/beluga_pharmacy_mappings.test";
142
148
  import { mdi_case_offerings_tests } from "./api_tests/mdi_case_offerings.test";
143
149
  import { beluga_manual_sync_tests } from "./api_tests/beluga_manual_sync.test";
150
+ import { beluga_trigger_refill_tests } from "./api_tests/beluga_trigger_refill.test";
144
151
  import { mdi_webhooks_tests } from "./api_tests/mdi_webhooks.test";
145
152
  import { account_switcher_tests } from "./api_tests/account_switcher.test";
146
153
  import { totp_mfa_tests } from "./api_tests/totp_mfa.test";
@@ -7951,9 +7958,49 @@ const merge_enduser_tests = async () => {
7951
7958
  { onResult: e => e.attendees.length === 1 && !!e.attendees?.find(a => a.id === destination.id) }
7952
7959
  )
7953
7960
 
7961
+ // merging two endusers that both lack insurance must leave the fields absent, NOT null.
7962
+ // a stored null breaks every later partial update: those get flattened to dotted paths
7963
+ // ('insurance.memberId'), and MongoDB rejects a dotted $set under a null parent
7964
+ const [noInsuranceSource, noInsuranceDestination] = (await sdk.api.endusers.createSome([
7965
+ { email: 'noins-source@tellescope.com', fname: 'no', lname: 'insurance' },
7966
+ { email: 'noins-destination@tellescope.com' },
7967
+ ])).created
7968
+
7969
+ await sdk.api.endusers.merge({
7970
+ sourceEnduserId: noInsuranceSource.id,
7971
+ destinationEnduserId: noInsuranceDestination.id,
7972
+ })
7973
+
7974
+ await async_test(
7975
+ "Merged endusers have no null insurance",
7976
+ () => sdk.api.endusers.getOne(noInsuranceDestination.id),
7977
+ { onResult: e => e.insurance === undefined && e.insuranceSecondary === undefined }
7978
+ )
7979
+
7980
+ await async_test(
7981
+ "Partial insurance update applies after merge",
7982
+ async () => {
7983
+ await sdk.api.endusers.updateOne(noInsuranceDestination.id, {
7984
+ insurance: { memberId: 'M1', payerName: 'Test Payer' },
7985
+ }) // no replaceObjectFields, matching the standard update from the UI
7986
+ return await sdk.api.endusers.getOne(noInsuranceDestination.id)
7987
+ },
7988
+ { onResult: e => e.insurance?.memberId === 'M1' && e.insurance?.payerName === 'Test Payer' }
7989
+ )
7990
+
7991
+ await async_test(
7992
+ "Second partial insurance update merges",
7993
+ async () => {
7994
+ await sdk.api.endusers.updateOne(noInsuranceDestination.id, { insurance: { groupNumber: 'G1' } })
7995
+ return await sdk.api.endusers.getOne(noInsuranceDestination.id)
7996
+ },
7997
+ { onResult: e => e.insurance?.groupNumber === 'G1' && e.insurance?.memberId === 'M1' }
7998
+ )
7999
+
7954
8000
  await Promise.all([
7955
8001
  sdk.api.endusers.deleteOne(destination.id),
7956
8002
  sdk.api.endusers.deleteOne(otherEnduser.id),
8003
+ sdk.api.endusers.deleteOne(noInsuranceDestination.id),
7957
8004
  sdk.api.calendar_events.deleteOne(event.id),
7958
8005
  sdk.api.calendar_events.deleteOne(eventToMove.id),
7959
8006
  ])
@@ -8587,6 +8634,8 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8587
8634
  enduser,
8588
8635
  closedForReason,
8589
8636
  testDelayedChild,
8637
+ calendarEventId,
8638
+ ticketOwner,
8590
8639
  } : {
8591
8640
  assignedTo: string[]
8592
8641
  info: Pick<CreateTicketActionInfo, 'assignmentStrategy' | 'defaultAssignee'>,
@@ -8594,6 +8643,8 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8594
8643
  closedForReason?: string,
8595
8644
  enduser?: Enduser,
8596
8645
  testDelayedChild?: boolean,
8646
+ calendarEventId?: string, // copied into journeyContext by close_ticket
8647
+ ticketOwner?: string, // override the seed ticket's owner, to avoid masking a previous-owner fallback
8597
8648
  }) => {
8598
8649
 
8599
8650
  const e = enduser || await sdk.api.endusers.createOne({ assignedTo, journeys: { [journey.id]: '' }, fields: { Tag: "tag3"} })
@@ -8631,8 +8682,9 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8631
8682
  enduserId: e.id,
8632
8683
  automationStepId: PLACEHOLDER_ID,
8633
8684
  journeyId: journey.id,
8634
- owner: validOwners[0],
8685
+ owner: ticketOwner ?? validOwners[0],
8635
8686
  closedForReason,
8687
+ ...(calendarEventId ? { calendarEventId } : {}),
8636
8688
  })
8637
8689
 
8638
8690
  await async_test(
@@ -8765,11 +8817,13 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8765
8817
  info,
8766
8818
  validOwners,
8767
8819
  enduser,
8820
+ journeyContext,
8768
8821
  } : {
8769
8822
  assignedTo: string[]
8770
8823
  info: Pick<CreateTicketActionInfo, 'assignmentStrategy' | 'defaultAssignee'>,
8771
8824
  validOwners: string[],
8772
8825
  enduser?: Enduser,
8826
+ journeyContext?: JourneyContext,
8773
8827
  }) => {
8774
8828
  const e = enduser || await sdk.api.endusers.createOne({ assignedTo, fields: { Tag: 'tag3' } })
8775
8829
  await sdk.api.automated_actions.createOne({
@@ -8780,6 +8834,7 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8780
8834
  journeyId: journey.id,
8781
8835
  status: 'active',
8782
8836
  processAfter: Date.now(),
8837
+ ...(journeyContext ? { journeyContext } : {}),
8783
8838
  })
8784
8839
 
8785
8840
  await async_test(
@@ -8880,6 +8935,115 @@ const test_ticket_automation_assignment_and_optimization = async () => {
8880
8935
  validOwners: [sdkNonAdmin.userInfo.id],
8881
8936
  })
8882
8937
 
8938
+ // 'Recently-Booked Appointment Host' assignment
8939
+ // journeyContext.calendarEventId, when it resolves to a host, takes priority over the legacy
8940
+ // "newest event for this enduser" lookup. one enduser per case, to avoid trigger throttling.
8941
+ const HOST_A = sdkNonAdmin.userInfo.id
8942
+ const HOST_B = sdk.userInfo.id
8943
+ const appointmentHostInfo = (defaultAssignee: string) => ({
8944
+ assignmentStrategy: { type: 'Recently-Booked Appointment Host', info: {} } as const,
8945
+ defaultAssignee,
8946
+ })
8947
+ // the legacy filter matches an event having *some* user attendee and *some* attendee with this
8948
+ // enduser's id, so each hosted event carries both
8949
+ const createHostedEvent = (enduserId: string, hostId: string) => sdk.api.calendar_events.createOne({
8950
+ title: 'appointment host test',
8951
+ durationInMinutes: 30,
8952
+ startTimeInMS: Date.now(),
8953
+ attendees: [{ type: 'enduser', id: enduserId }, { type: 'user', id: hostId }],
8954
+ })
8955
+
8956
+ // CORE PROOF: two appointments with different hosts, journey context names the OLDER one.
8957
+ // fails before this fix, which always took the newest-created event (HOST_B).
8958
+ {
8959
+ const e = await sdk.api.endusers.createOne({ fields: { Tag: 'tag3' } })
8960
+ const older = await createHostedEvent(e.id, HOST_A)
8961
+ const newer = await createHostedEvent(e.id, HOST_B) // higher _id, so legacy sort picks this one
8962
+ await testBackgroundTicket({
8963
+ assignedTo: [],
8964
+ enduser: e,
8965
+ info: appointmentHostInfo(HOST_B), // distinct from expected owner, so a fallback would fail
8966
+ validOwners: [HOST_A],
8967
+ journeyContext: { calendarEventId: older.id },
8968
+ })
8969
+ await Promise.all([older, newer].map(ev => sdk.api.calendar_events.deleteOne(ev.id)))
8970
+ }
8971
+
8972
+ // no journey context -> legacy "newest event" behavior is unchanged
8973
+ {
8974
+ const e = await sdk.api.endusers.createOne({ fields: { Tag: 'tag3' } })
8975
+ const older = await createHostedEvent(e.id, HOST_A)
8976
+ const newer = await createHostedEvent(e.id, HOST_B)
8977
+ await testBackgroundTicket({
8978
+ assignedTo: [],
8979
+ enduser: e,
8980
+ info: appointmentHostInfo(HOST_A),
8981
+ validOwners: [HOST_B], // newest created event's host
8982
+ })
8983
+ await Promise.all([older, newer].map(ev => sdk.api.calendar_events.deleteOne(ev.id)))
8984
+ }
8985
+
8986
+ // context event no longer exists -> falls back to the legacy lookup
8987
+ {
8988
+ const e = await sdk.api.endusers.createOne({ fields: { Tag: 'tag3' } })
8989
+ const deleted = await createHostedEvent(e.id, HOST_B)
8990
+ await sdk.api.calendar_events.deleteOne(deleted.id)
8991
+ const hosted = await createHostedEvent(e.id, HOST_A)
8992
+ await testBackgroundTicket({
8993
+ assignedTo: [],
8994
+ enduser: e,
8995
+ info: appointmentHostInfo(HOST_B),
8996
+ validOwners: [HOST_A],
8997
+ journeyContext: { calendarEventId: deleted.id },
8998
+ })
8999
+ await sdk.api.calendar_events.deleteOne(hosted.id)
9000
+ }
9001
+
9002
+ // context event has no user attendee (so yields no host) -> falls back to the legacy lookup
9003
+ {
9004
+ const e = await sdk.api.endusers.createOne({ fields: { Tag: 'tag3' } })
9005
+ const enduserOnly = await sdk.api.calendar_events.createOne({
9006
+ title: 'appointment host test, no host',
9007
+ durationInMinutes: 30,
9008
+ startTimeInMS: Date.now(),
9009
+ attendees: [{ type: 'enduser', id: e.id }],
9010
+ })
9011
+ const hosted = await createHostedEvent(e.id, HOST_A)
9012
+ await testBackgroundTicket({
9013
+ assignedTo: [],
9014
+ enduser: e,
9015
+ info: appointmentHostInfo(HOST_B),
9016
+ validOwners: [HOST_A],
9017
+ journeyContext: { calendarEventId: enduserOnly.id },
9018
+ })
9019
+ await Promise.all([enduserOnly, hosted].map(ev => sdk.api.calendar_events.deleteOne(ev.id)))
9020
+ }
9021
+
9022
+ // no events at all -> no resolvable host, so defaultAssignee wins
9023
+ await testBackgroundTicket({
9024
+ assignedTo: [],
9025
+ info: appointmentHostInfo(HOST_A),
9026
+ validOwners: [HOST_A],
9027
+ })
9028
+
9029
+ // api path: close_ticket copies the original ticket's calendarEventId into journeyContext
9030
+ {
9031
+ const e = await sdk.api.endusers.createOne({
9032
+ journeys: { [journey.id]: '' }, fields: { Tag: 'tag3' },
9033
+ })
9034
+ const older = await createHostedEvent(e.id, HOST_A)
9035
+ const newer = await createHostedEvent(e.id, HOST_B)
9036
+ await testForegroundTicket({
9037
+ assignedTo: [],
9038
+ enduser: e,
9039
+ info: appointmentHostInfo(HOST_B),
9040
+ validOwners: [HOST_A],
9041
+ calendarEventId: older.id,
9042
+ ticketOwner: HOST_B, // so a previous-owner style fallback would also fail
9043
+ })
9044
+ await Promise.all([older, newer].map(ev => sdk.api.calendar_events.deleteOne(ev.id)))
9045
+ }
9046
+
8883
9047
  return Promise.all([
8884
9048
  await sdk.api.journeys.deleteOne(journey.id),
8885
9049
  await sdk.api.ticket_queues.deleteOne(queue.id),
@@ -13764,6 +13928,55 @@ const replace_form_field_template_values_tests = async () => {
13764
13928
  ) === '<p>Locations: NYC<br />SF<br />LA</p>',
13765
13929
  'fail CRLF in substituted value', 'CRLF in substituted value'
13766
13930
  )
13931
+
13932
+ // escapeHTMLValues: substituted values are patient-supplied (their own intake answers / enduser
13933
+ // record), so on any path rendered by a sanitizer that permits iframes they must be escaped —
13934
+ // otherwise a patient can inject a frame into an admin-authored description.
13935
+ const enduserWithMarkupField = {
13936
+ fname: "Multi",
13937
+ fields: { Injected: '<iframe src="https://evil.example"></iframe>' },
13938
+ } as Partial<Enduser>
13939
+
13940
+ const escaped = replace_form_field_template_values(
13941
+ '<p>{{enduser.Injected}}</p>',
13942
+ { enduser: enduserWithMarkupField, escapeNewlinesAsHTMLBreaks: true, escapeHTMLValues: true }
13943
+ )
13944
+ assert(
13945
+ escaped.includes('&lt;iframe') && !escaped.includes('<iframe'),
13946
+ 'fail escapeHTMLValues escapes markup', 'escapeHTMLValues escapes markup'
13947
+ )
13948
+
13949
+ // Escaping must run BEFORE the newline conversion, or the injected breaks become &lt;br /&gt;
13950
+ const enduserWithMarkupAndNewline = {
13951
+ fields: { Mixed: 'a<b\nc&d' },
13952
+ } as Partial<Enduser>
13953
+ assert(
13954
+ replace_form_field_template_values(
13955
+ '{{enduser.Mixed}}',
13956
+ { enduser: enduserWithMarkupAndNewline, escapeNewlinesAsHTMLBreaks: true, escapeHTMLValues: true }
13957
+ ) === 'a&lt;b<br />c&amp;d',
13958
+ 'fail escapeHTMLValues ordering vs newline breaks', 'escapeHTMLValues ordering vs newline breaks'
13959
+ )
13960
+
13961
+ // Flag absent => byte-identical to today. Plain-text callers (field titles/descriptions rendered
13962
+ // in a Typography) rely on this: escaping there would corrupt e.g. a name containing '&'.
13963
+ const enduserWithAmpersand = {
13964
+ fields: { Practice: 'Smith & Jones <Cardiology>' },
13965
+ } as Partial<Enduser>
13966
+ assert(
13967
+ replace_form_field_template_values(
13968
+ 'Practice: {{enduser.Practice}}',
13969
+ { enduser: enduserWithAmpersand }
13970
+ ) === 'Practice: Smith & Jones <Cardiology>',
13971
+ 'fail no escaping without the flag', 'no escaping without the flag'
13972
+ )
13973
+ assert(
13974
+ replace_form_field_template_values(
13975
+ 'Practice: {{enduser.Practice}}',
13976
+ { enduser: enduserWithAmpersand, escapeHTMLValues: true }
13977
+ ) === 'Practice: Smith &amp; Jones &lt;Cardiology&gt;',
13978
+ 'fail escaping with the flag', 'escaping with the flag'
13979
+ )
13767
13980
  }
13768
13981
 
13769
13982
  const inbox_threads_building_tests = async () => {
@@ -15065,6 +15278,8 @@ const ip_address_form_tests = async () => {
15065
15278
  await replace_form_field_template_values_tests()
15066
15279
  await mfa_tests()
15067
15280
  await setup_tests(sdk, sdkNonAdmin)
15281
+ await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
15282
+ await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
15068
15283
  await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
15069
15284
  await phone_calls_conference_hold_tests({ sdk, sdkNonAdmin })
15070
15285
  await tickets_bulk_assign_care_team_tests({ sdk, sdkNonAdmin })
@@ -15073,6 +15288,7 @@ const ip_address_form_tests = async () => {
15073
15288
  await automation_trigger_tests()
15074
15289
  await resource_access_tags_tests({ sdk, sdkNonAdmin })
15075
15290
  await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
15291
+ await beluga_trigger_refill_tests({ sdk, sdkNonAdmin })
15076
15292
  await beluga_pharmacy_mappings_tests({ sdk, sdkNonAdmin })
15077
15293
  await mdi_case_offerings_tests({ sdk, sdkNonAdmin })
15078
15294
  await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
@@ -15095,6 +15311,7 @@ const ip_address_form_tests = async () => {
15095
15311
  await cascade_role_rename_cross_tenant_tests({ sdk, sdkNonAdmin })
15096
15312
  await self_admin_role_assignment_tests({ sdk, sdkNonAdmin })
15097
15313
  await sanitize_user_html_xss_tests()
15314
+ await sanitize_user_html_with_iframes_tests()
15098
15315
  await prototype_pollution_tests()
15099
15316
  await account_switcher_tests({ sdk, sdkNonAdmin })
15100
15317
  await totp_mfa_tests({ sdk, sdkNonAdmin })
@@ -15110,6 +15327,7 @@ const ip_address_form_tests = async () => {
15110
15327
  await chats_analytics_tests({ sdk, sdkNonAdmin })
15111
15328
  await field_redaction_tests({ sdk, sdkNonAdmin })
15112
15329
  await form_submitted_trigger_tests({ sdk, sdkNonAdmin })
15330
+ await start_ai_conversation_tests({ sdk, sdkNonAdmin })
15113
15331
  await dont_sync_to_elation_form_submission_tests({ sdk, sdkNonAdmin })
15114
15332
  await date_string_validation_tests({ sdk, sdkNonAdmin })
15115
15333
  await phone_tree_enduser_condition_tests({ sdk, sdkNonAdmin })
@@ -15129,6 +15347,7 @@ const ip_address_form_tests = async () => {
15129
15347
  await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
15130
15348
  await calendar_event_limits_tests({ sdk, sdkNonAdmin })
15131
15349
  await get_some_projection_tests({ sdk, sdkNonAdmin })
15350
+ await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
15132
15351
  await elation_user_id_tests({ sdk, sdkNonAdmin })
15133
15352
  await concurrent_build_threads_tests({ sdk, sdkNonAdmin })
15134
15353
  await custom_aggregation_tests({ sdk, sdkNonAdmin })
Binary file