@tellescope/sdk 1.256.0 → 1.256.2

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 (59) hide show
  1. package/lib/cjs/sdk.d.ts +2 -2
  2. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
  3. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
  4. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js +163 -0
  5. package/lib/cjs/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
  6. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  7. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  8. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
  9. package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  10. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
  11. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js +354 -0
  13. package/lib/cjs/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
  14. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  15. package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  16. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
  17. package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  18. package/lib/cjs/tests/tests.d.ts.map +1 -1
  19. package/lib/cjs/tests/tests.js +217 -189
  20. package/lib/cjs/tests/tests.js.map +1 -1
  21. package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts +3 -0
  22. package/lib/cjs/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/unit_tests/phone_validation.test.js +96 -0
  24. package/lib/cjs/tests/unit_tests/phone_validation.test.js.map +1 -0
  25. package/lib/esm/sdk.d.ts +2 -2
  26. package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts +6 -0
  27. package/lib/esm/tests/api_tests/appointment_link_expiration.test.d.ts.map +1 -0
  28. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js +156 -0
  29. package/lib/esm/tests/api_tests/appointment_link_expiration.test.js.map +1 -0
  30. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
  31. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
  32. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
  33. package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
  34. package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts +6 -0
  35. package/lib/esm/tests/api_tests/embeddables_auth_token.test.d.ts.map +1 -0
  36. package/lib/esm/tests/api_tests/embeddables_auth_token.test.js +347 -0
  37. package/lib/esm/tests/api_tests/embeddables_auth_token.test.js.map +1 -0
  38. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
  39. package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
  40. package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
  41. package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
  42. package/lib/esm/tests/tests.d.ts.map +1 -1
  43. package/lib/esm/tests/tests.js +217 -189
  44. package/lib/esm/tests/tests.js.map +1 -1
  45. package/lib/esm/tests/unit_tests/phone_validation.test.d.ts +3 -0
  46. package/lib/esm/tests/unit_tests/phone_validation.test.d.ts.map +1 -0
  47. package/lib/esm/tests/unit_tests/phone_validation.test.js +93 -0
  48. package/lib/esm/tests/unit_tests/phone_validation.test.js.map +1 -0
  49. package/lib/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +10 -10
  51. package/src/tests/api_tests/appointment_link_expiration.test.ts +92 -0
  52. package/src/tests/api_tests/beluga_trigger_refill.test.ts +152 -0
  53. package/src/tests/api_tests/embeddables_auth_token.test.ts +417 -0
  54. package/src/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.ts +292 -0
  55. package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
  56. package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
  57. package/src/tests/tests.ts +14 -0
  58. package/src/tests/unit_tests/phone_validation.test.ts +108 -0
  59. 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
+ }
@@ -38,6 +38,7 @@ import {
38
38
  import { Session, APIQuery, EnduserSession } from "../sdk"
39
39
  import { enduser_observations_acknowledge_tests } from "./api_tests/enduser_observations_acknowledge.test"
40
40
  import { translations_tests } from "./api_tests/translations.test"
41
+ import { embeddables_auth_token_tests } from "./api_tests/embeddables_auth_token.test"
41
42
  import { user_portal_settings_tests } from "./api_tests/user_portal_settings.test"
42
43
  import { integrations_redacted_tests } from "./api_tests/integrations_redacted.test"
43
44
  import { healthie_multi_integration_tests } from "./api_tests/healthie_multi_integration.test"
@@ -97,6 +98,7 @@ import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent
97
98
  import { time_tracks_tests, time_tracks_historical_tests, time_tracks_correction_tests, time_tracks_review_tests, time_tracks_lock_tests, time_tracks_edge_case_tests, time_tracks_resubmit_tests, time_tracks_appointment_duration_tests } from "./api_tests/time_tracks.test";
98
99
  import { monthly_availability_restrictions_tests } from "./api_tests/monthly_availability_restrictions.test";
99
100
  import { calendar_event_limits_tests } from "./api_tests/calendar_event_limits.test";
101
+ import { appointment_link_expiration_tests } from "./api_tests/appointment_link_expiration.test";
100
102
  import { custom_aggregation_tests } from "./api_tests/custom_aggregation.test";
101
103
  import { chats_analytics_tests } from "./api_tests/chats_analytics.test";
102
104
  import { nutrition_tracking_tests } from "./api_tests/nutrition_tracking.test";
@@ -120,6 +122,8 @@ import { load_inbox_redaction_tests } from "./api_tests/security/F-0151-load-inb
120
122
  import { webhook_timeout_tests } from "./api_tests/security/F-0155-webhook-timeout.test";
121
123
  import { enduser_ai_summary_trust_gate_tests } from "./api_tests/security/enduser-ai-summary-trust-gate.test";
122
124
  import { allowed_paths_confinement_tests } from "./api_tests/security/F-0166-allowed-paths-wildcard-bypass.test";
125
+ import { proxy_image_ssrf_tests } from "./api_tests/security/F-0025-proxy-image-unauth-ssrf.test";
126
+ import { webhook_outbound_ssrf_tests } from "./api_tests/security/F-0015-webhook-outbound-ssrf.test";
123
127
  import { public_endpoint_auth_field_lock_tests } from "./api_tests/security/public_endpoint_auth_field_lock.test";
124
128
  import { formsort_webhook_auth_field_lock_tests } from "./api_tests/security/formsort_webhook_auth_field_lock.test";
125
129
  import { bulk_assignment_tests } from "./api_tests/bulk_assignment.test";
@@ -133,9 +137,11 @@ import { load_team_chat_tests } from "./api_tests/load_team_chat.test";
133
137
  import { form_started_trigger_tests } from "./api_tests/form_started_trigger.test";
134
138
  import { formsort_header_auth_tests } from "./api_tests/formsort_header_auth.test";
135
139
  import { form_submitted_trigger_tests } from "./api_tests/form_submitted_trigger.test";
140
+ import { start_ai_conversation_tests } from "./api_tests/start_ai_conversation.test";
136
141
  import { dont_sync_to_elation_form_submission_tests } from "./api_tests/dont_sync_to_elation_form_submission.test";
137
142
  import { medication_added_trigger_tests } from "./api_tests/medication_added_trigger.test";
138
143
  import { conditional_logic_medication_unit_tests } from "./unit_tests/conditional_logic_medication.test";
144
+ import { phone_validation_unit_tests } from "./unit_tests/phone_validation.test";
139
145
  import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
140
146
  import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
141
147
  import { calendar_events_bulk_update_tests } from "./api_tests/calendar_events_bulk_update.test";
@@ -144,6 +150,7 @@ import { openloop_webhooks_tests } from "./api_tests/openloop_webhooks.test";
144
150
  import { beluga_pharmacy_mappings_tests } from "./api_tests/beluga_pharmacy_mappings.test";
145
151
  import { mdi_case_offerings_tests } from "./api_tests/mdi_case_offerings.test";
146
152
  import { beluga_manual_sync_tests } from "./api_tests/beluga_manual_sync.test";
153
+ import { beluga_trigger_refill_tests } from "./api_tests/beluga_trigger_refill.test";
147
154
  import { mdi_webhooks_tests } from "./api_tests/mdi_webhooks.test";
148
155
  import { account_switcher_tests } from "./api_tests/account_switcher.test";
149
156
  import { totp_mfa_tests } from "./api_tests/totp_mfa.test";
@@ -15270,10 +15277,14 @@ const ip_address_form_tests = async () => {
15270
15277
 
15271
15278
  await enduser_conditional_logic_tests()
15272
15279
  await conditional_logic_medication_unit_tests()
15280
+ await phone_validation_unit_tests()
15273
15281
  await replace_enduser_template_values_tests()
15274
15282
  await replace_form_field_template_values_tests()
15275
15283
  await mfa_tests()
15276
15284
  await setup_tests(sdk, sdkNonAdmin)
15285
+ await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
15286
+ await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
15287
+ await webhook_outbound_ssrf_tests({ sdk, sdkNonAdmin })
15277
15288
  await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
15278
15289
  await phone_calls_conference_hold_tests({ sdk, sdkNonAdmin })
15279
15290
  await tickets_bulk_assign_care_team_tests({ sdk, sdkNonAdmin })
@@ -15282,6 +15293,7 @@ const ip_address_form_tests = async () => {
15282
15293
  await automation_trigger_tests()
15283
15294
  await resource_access_tags_tests({ sdk, sdkNonAdmin })
15284
15295
  await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
15296
+ await beluga_trigger_refill_tests({ sdk, sdkNonAdmin })
15285
15297
  await beluga_pharmacy_mappings_tests({ sdk, sdkNonAdmin })
15286
15298
  await mdi_case_offerings_tests({ sdk, sdkNonAdmin })
15287
15299
  await enduser_write_restrictions_tests({ sdk, sdkNonAdmin })
@@ -15320,6 +15332,7 @@ const ip_address_form_tests = async () => {
15320
15332
  await chats_analytics_tests({ sdk, sdkNonAdmin })
15321
15333
  await field_redaction_tests({ sdk, sdkNonAdmin })
15322
15334
  await form_submitted_trigger_tests({ sdk, sdkNonAdmin })
15335
+ await start_ai_conversation_tests({ sdk, sdkNonAdmin })
15323
15336
  await dont_sync_to_elation_form_submission_tests({ sdk, sdkNonAdmin })
15324
15337
  await date_string_validation_tests({ sdk, sdkNonAdmin })
15325
15338
  await phone_tree_enduser_condition_tests({ sdk, sdkNonAdmin })
@@ -15338,6 +15351,7 @@ const ip_address_form_tests = async () => {
15338
15351
  await time_tracks_resubmit_tests({ sdk, sdkNonAdmin })
15339
15352
  await time_tracks_appointment_duration_tests({ sdk, sdkNonAdmin })
15340
15353
  await calendar_event_limits_tests({ sdk, sdkNonAdmin })
15354
+ await appointment_link_expiration_tests({ sdk, sdkNonAdmin })
15341
15355
  await get_some_projection_tests({ sdk, sdkNonAdmin })
15342
15356
  await file_download_unicode_names_tests({ sdk, sdkNonAdmin })
15343
15357
  await elation_user_id_tests({ sdk, sdkNonAdmin })
@@ -0,0 +1,108 @@
1
+ import {
2
+ parse_phone_number,
3
+ phoneValidator,
4
+ phoneValidatorOptional,
5
+ } from "@tellescope/validation"
6
+ import { to_human_readable_phone_number } from "@tellescope/utilities"
7
+
8
+ let failures = 0
9
+ let passed = 0
10
+
11
+ const assert = (name: string, actual: unknown, expected: unknown) => {
12
+ if (actual === expected) {
13
+ passed++
14
+ console.log(` ✓ ${name}`)
15
+ } else {
16
+ failures++
17
+ console.error(` ✗ ${name} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
18
+ }
19
+ }
20
+
21
+ // validators throw on invalid input; THREW lets us assert rejection without try/catch per case
22
+ const THREW = 'THREW'
23
+ const attempt = (validate: (v: any) => string, value: string) => {
24
+ try { return validate(value) } catch (err) { return THREW }
25
+ }
26
+
27
+ // phoneValidator and phoneValidatorOptional carry identical parsing logic and differ only in
28
+ // their build_validator options, so every parsing case runs against both.
29
+ const run_validator_tests = () => {
30
+ console.log("\n[phoneValidator / phoneValidatorOptional]")
31
+
32
+ const cases: [string, string, string][] = [
33
+ // [input, expected, description]
34
+ ['+14155551234', '+14155551234', 'normalized E.164 passes through'],
35
+ ['4155551234', '+14155551234', 'bare 10 digits gets a US country code'],
36
+ ['(415) 555-1234', '+14155551234', 'formatted number is normalized'],
37
+ [' 415-555-1234 ', '+14155551234', 'whitespace and dashes are stripped'],
38
+ ['311', '311', 'N11 service code passes through (pre-existing behavior)'],
39
+
40
+ // Short codes: valid SMS endpoints, but not E.164 — no country code, no '+'
41
+ ['894546', '894546', '6-digit short code passes through unchanged'],
42
+ ['89454', '89454', '5-digit short code passes through unchanged'],
43
+ ['00000', '00000', 'leading zeroes are preserved, not normalized away'],
44
+
45
+ // The short-code allowance must not widen past bare 5-6 digits
46
+ ['+1894546', THREW, 'a leading + forces the strict E.164 path'],
47
+ ['1234', THREW, '4 digits still rejects'],
48
+ ['1234567', THREW, '7 digits still rejects'],
49
+ ['123456789', THREW, '9 digits still rejects'],
50
+ ['12', THREW, 'too-few digits still rejects'],
51
+ ['not a phone', THREW, 'junk still rejects'],
52
+ ]
53
+
54
+ for (const [input, expected, description] of cases) {
55
+ assert(`${description} (required)`, attempt(phoneValidator.validate(), input), expected)
56
+ assert(`${description} (optional)`, attempt(phoneValidatorOptional.validate(), input), expected)
57
+ }
58
+
59
+ assert(
60
+ 'optional variant accepts empty string',
61
+ attempt(phoneValidatorOptional.validate(), ''),
62
+ '',
63
+ )
64
+ }
65
+
66
+ // parse_phone_number is the non-throwing wrapper used on integration payloads, where a junk
67
+ // number must not fail the whole request. It inherits the short-code rule from phoneValidator.
68
+ const run_parse_tests = () => {
69
+ console.log("\n[parse_phone_number]")
70
+
71
+ assert('E.164 passes through', parse_phone_number('+14155551234'), '+14155551234')
72
+ assert('bare 10 digits normalizes', parse_phone_number('4155551234'), '+14155551234')
73
+ assert('short code passes through', parse_phone_number('894546'), '894546')
74
+ assert('7 digits returns undefined', parse_phone_number('1234567'), undefined)
75
+ assert('junk returns undefined', parse_phone_number('not a phone'), undefined)
76
+ assert('empty string returns undefined', parse_phone_number(''), undefined)
77
+ assert('undefined returns undefined', parse_phone_number(undefined), undefined)
78
+ assert('null returns undefined', parse_phone_number(null), undefined)
79
+ }
80
+
81
+ // Guards the display formatter against the negative-slice bug: before the length check,
82
+ // a 6-digit short code rendered as '+ (894) 546-'
83
+ const run_display_tests = () => {
84
+ console.log("\n[to_human_readable_phone_number]")
85
+
86
+ assert('E.164 formats with country code', to_human_readable_phone_number('+15555555555'), '+1 (555) 555-5555')
87
+ assert('bare 10 digits formats', to_human_readable_phone_number('5555555555'), '(555) 555-5555')
88
+ assert('6-digit short code is returned as-is', to_human_readable_phone_number('894546'), '894546')
89
+ assert('5-digit short code is returned as-is', to_human_readable_phone_number('89454'), '89454')
90
+ assert('311 is returned as-is', to_human_readable_phone_number('311'), '311')
91
+ assert('empty string returns empty string', to_human_readable_phone_number(''), '')
92
+ assert('undefined returns empty string', to_human_readable_phone_number(undefined), '')
93
+ }
94
+
95
+ const run_all = () => {
96
+ console.log("Running phone_validation unit tests")
97
+ run_validator_tests()
98
+ run_parse_tests()
99
+ run_display_tests()
100
+ console.log(`\nResults: ${passed} passed, ${failures} failed`)
101
+ if (failures > 0) process.exit(1)
102
+ }
103
+
104
+ if (require.main === module) {
105
+ run_all()
106
+ }
107
+
108
+ export { run_all as phone_validation_unit_tests }
Binary file