@tellescope/sdk 1.250.2 → 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 (64) 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_login.test.d.ts +6 -0
  9. package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  10. package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
  11. package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
  12. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +370 -0
  15. package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  16. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
  19. package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  20. package/lib/cjs/tests/setup.d.ts.map +1 -1
  21. package/lib/cjs/tests/setup.js +47 -32
  22. package/lib/cjs/tests/setup.js.map +1 -1
  23. package/lib/cjs/tests/tests.d.ts.map +1 -1
  24. package/lib/cjs/tests/tests.js +179 -158
  25. package/lib/cjs/tests/tests.js.map +1 -1
  26. package/lib/esm/sdk.d.ts +9 -0
  27. package/lib/esm/sdk.d.ts.map +1 -1
  28. package/lib/esm/sdk.js +3 -0
  29. package/lib/esm/sdk.js.map +1 -1
  30. package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
  31. package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
  32. package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
  33. package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
  34. package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
  35. package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
  36. package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
  37. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
  38. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
  39. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
  40. package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
  41. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
  42. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
  43. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +366 -0
  44. package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
  45. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
  46. package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
  47. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
  48. package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
  49. package/lib/esm/tests/setup.d.ts.map +1 -1
  50. package/lib/esm/tests/setup.js +47 -32
  51. package/lib/esm/tests/setup.js.map +1 -1
  52. package/lib/esm/tests/tests.d.ts.map +1 -1
  53. package/lib/esm/tests/tests.js +179 -158
  54. package/lib/esm/tests/tests.js.map +1 -1
  55. package/lib/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +10 -10
  57. package/src/sdk.ts +12 -0
  58. package/src/tests/api_tests/account_switcher.test.ts +1283 -0
  59. package/src/tests/api_tests/enduser_login.test.ts +215 -0
  60. package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +198 -0
  61. package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
  62. package/src/tests/setup.ts +8 -1
  63. package/src/tests/tests.ts +18 -5
  64. package/test_generated.pdf +0 -0
@@ -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
+ }
@@ -0,0 +1,198 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import { log_header, wait, async_test } from "@tellescope/testing"
5
+ import { Enduser } from "@tellescope/types-client"
6
+ import { setup_tests } from "../setup"
7
+
8
+ const host = process.env.API_URL || "http://localhost:8080"
9
+
10
+ const pollFor = async <T>(
11
+ fetchFn: () => Promise<T | undefined>,
12
+ evaluateFn: (result: T | undefined) => result is T,
13
+ description: string,
14
+ intervalMs = 500,
15
+ maxIterations = 30,
16
+ ): Promise<T> => {
17
+ let lastResult: T | undefined
18
+ for (let i = 0; i < maxIterations; i++) {
19
+ await wait(undefined, intervalMs)
20
+ lastResult = await fetchFn()
21
+ if (evaluateFn(lastResult)) return lastResult
22
+ }
23
+ throw new Error(`Polling timeout: ${description} - waited ${maxIterations * intervalMs}ms`)
24
+ }
25
+
26
+ export const push_forms_to_portal_group_completion_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
27
+ log_header("Push Forms To Portal - Form Group Completed Trigger Tests")
28
+
29
+ const createdEnduserIds: string[] = []
30
+ const createdJourneyIds: string[] = []
31
+ const createdFormIds: string[] = []
32
+ const createdFormGroupIds: string[] = []
33
+ const createdTriggerIds: string[] = []
34
+
35
+ try {
36
+ // 1. Create two forms, each with a single text field
37
+ const formA = await sdk.api.forms.createOne({ title: 'Push To Portal Form A' })
38
+ createdFormIds.push(formA.id)
39
+ const fieldA = await sdk.api.form_fields.createOne({
40
+ formId: formA.id,
41
+ type: 'string',
42
+ title: 'FieldA',
43
+ previousFields: [{ type: 'root', info: {} }],
44
+ })
45
+
46
+ const formB = await sdk.api.forms.createOne({ title: 'Push To Portal Form B' })
47
+ createdFormIds.push(formB.id)
48
+ const fieldB = await sdk.api.form_fields.createOne({
49
+ formId: formB.id,
50
+ type: 'string',
51
+ title: 'FieldB',
52
+ previousFields: [{ type: 'root', info: {} }],
53
+ })
54
+
55
+ // 2. Create a form group containing both forms
56
+ const formGroup = await sdk.api.form_groups.createOne({
57
+ title: 'Push To Portal Test Group',
58
+ formIds: [formA.id, formB.id],
59
+ })
60
+ createdFormGroupIds.push(formGroup.id)
61
+
62
+ // 3. Configure trigger with event.info.groupId = the real formGroupId
63
+ const trigger = await sdk.api.automation_triggers.createOne({
64
+ event: { type: 'Form Group Completed', info: { groupId: formGroup.id } },
65
+ action: { type: 'Add Tags', info: { tags: ['form-group-completed-push'] } },
66
+ status: 'Active',
67
+ title: 'Form Group Completed - Push to Portal',
68
+ })
69
+ createdTriggerIds.push(trigger.id)
70
+
71
+ // 4. Create journey with a pushFormsToPortal step referencing the form group
72
+ const journey = await sdk.api.journeys.createOne({
73
+ title: 'Push To Portal Trigger Journey',
74
+ })
75
+ createdJourneyIds.push(journey.id)
76
+
77
+ const pushStep = await sdk.api.automation_steps.createOne({
78
+ journeyId: journey.id,
79
+ action: { type: 'pushFormsToPortal', info: { formGroupIds: [formGroup.id] } },
80
+ events: [{ type: 'onJourneyStart', info: {} }],
81
+ })
82
+
83
+ // 5. Create enduser and add to journey
84
+ const enduser = await sdk.api.endusers.createOne({ fname: 'PushPortal', lname: 'Tester' })
85
+ createdEnduserIds.push(enduser.id)
86
+
87
+ await sdk.api.endusers.add_to_journey({
88
+ enduserIds: [enduser.id],
89
+ journeyId: journey.id,
90
+ })
91
+
92
+ // 6. Poll for the worker to create the push-to-portal form_responses
93
+ const pushedResponses = await pollFor(
94
+ async () => {
95
+ const responses = await sdk.api.form_responses.getSome({
96
+ filter: { enduserId: enduser.id },
97
+ })
98
+ const pushed = responses.filter(r => !!r.pushedToPortalAt)
99
+ return pushed.length >= 2 ? pushed : undefined
100
+ },
101
+ (result): result is any[] => Array.isArray(result) && result.length >= 2,
102
+ 'pushed-to-portal form_responses to be created by worker',
103
+ 500,
104
+ 40,
105
+ )
106
+
107
+ // 7. Assert worker behavior: groupId === automationStepId and pushedToPortalAt is set
108
+ for (const fr of pushedResponses) {
109
+ if (!fr.pushedToPortalAt) {
110
+ throw new Error(`Expected pushedToPortalAt to be set on form_response ${fr.id}`)
111
+ }
112
+ if (fr.groupId !== pushStep.id) {
113
+ throw new Error(`Expected form_response.groupId (${fr.groupId}) to equal automation step id (${pushStep.id})`)
114
+ }
115
+ if (fr.automationStepId !== pushStep.id) {
116
+ throw new Error(`Expected form_response.automationStepId (${fr.automationStepId}) to equal automation step id (${pushStep.id})`)
117
+ }
118
+ }
119
+
120
+ await async_test(
121
+ "Worker writes groupId === automationStepId and pushedToPortalAt set",
122
+ async () => true,
123
+ { onResult: r => r === true },
124
+ )
125
+
126
+ // 8. Submit every form_response on behalf of the enduser
127
+ // Identify which form_response corresponds to formA / formB via formId
128
+ for (const fr of pushedResponses) {
129
+ const isFormA = fr.formId === formA.id
130
+ const targetFieldId = isFormA ? fieldA.id : fieldB.id
131
+ const targetFieldTitle = isFormA ? 'FieldA' : 'FieldB'
132
+ await sdk.api.form_responses.submit_form_response({
133
+ accessCode: fr.accessCode as string,
134
+ responses: [{
135
+ fieldId: targetFieldId,
136
+ fieldTitle: targetFieldTitle,
137
+ answer: { type: 'string', value: 'pushed-portal-answer' },
138
+ }],
139
+ })
140
+ }
141
+
142
+ // 9. Poll for the trigger's side-effect (tag on enduser)
143
+ await pollFor(
144
+ async () => {
145
+ const e = await sdk.api.endusers.getOne(enduser.id)
146
+ return e.tags?.includes('form-group-completed-push') ? e : undefined
147
+ },
148
+ (result): result is Enduser => !!result,
149
+ 'Form Group Completed trigger to apply tag after push-to-portal submissions',
150
+ 500,
151
+ 30,
152
+ )
153
+
154
+ await async_test(
155
+ "Form Group Completed trigger fires for push-to-portal completion",
156
+ () => sdk.api.endusers.getOne(enduser.id),
157
+ { onResult: (e: Enduser) => !!e.tags?.includes('form-group-completed-push') },
158
+ )
159
+
160
+ } finally {
161
+ for (const id of createdTriggerIds) {
162
+ try { await sdk.api.automation_triggers.deleteOne(id) } catch (e) { /* ignore */ }
163
+ }
164
+ for (const id of createdEnduserIds) {
165
+ try { await sdk.api.endusers.deleteOne(id) } catch (e) { /* ignore */ }
166
+ }
167
+ for (const id of createdJourneyIds) {
168
+ try { await sdk.api.journeys.deleteOne(id) } catch (e) { /* ignore */ }
169
+ }
170
+ for (const id of createdFormGroupIds) {
171
+ try { await sdk.api.form_groups.deleteOne(id) } catch (e) { /* ignore */ }
172
+ }
173
+ for (const id of createdFormIds) {
174
+ try { await sdk.api.forms.deleteOne(id) } catch (e) { /* ignore */ }
175
+ }
176
+ }
177
+ }
178
+
179
+ if (require.main === module) {
180
+ console.log(`🌐 Using API URL: ${host}`)
181
+ const sdk = new Session({ host })
182
+ const sdkNonAdmin = new Session({ host })
183
+
184
+ const runTests = async () => {
185
+ await setup_tests(sdk, sdkNonAdmin)
186
+ await push_forms_to_portal_group_completion_tests({ sdk, sdkNonAdmin })
187
+ }
188
+
189
+ runTests()
190
+ .then(() => {
191
+ console.log("✅ Push forms to portal group completion test suite completed successfully")
192
+ process.exit(0)
193
+ })
194
+ .catch((error) => {
195
+ console.error("❌ Push forms to portal group completion test suite failed:", error)
196
+ process.exit(1)
197
+ })
198
+ }
@@ -0,0 +1,258 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ assert,
6
+ async_test,
7
+ log_header,
8
+ wait,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ const TRIGGER_TITLE_BLOCK_A = "Order Templates: Block A"
15
+ const TRIGGER_TITLE_BLOCK_B = "Order Templates: Block B"
16
+
17
+ const buildSetFieldsAction = (prefix: string) => ({
18
+ type: 'Set Fields' as const,
19
+ info: {
20
+ fields: [
21
+ { name: `${prefix}_status`, value: '{{order.status}}', type: 'Custom Value' as const },
22
+ { name: `${prefix}_tracking`, value: '{{order.tracking}}', type: 'Custom Value' as const },
23
+ { name: `${prefix}_carrier`, value: '{{order.carrier}}', type: 'Custom Value' as const },
24
+ { name: `${prefix}_sku`, value: '{{order.sku}}', type: 'Custom Value' as const },
25
+ { name: `${prefix}_externalId`, value: '{{order.externalId}}', type: 'Custom Value' as const },
26
+ { name: `${prefix}_id`, value: '{{order.id}}', type: 'Custom Value' as const },
27
+ { name: `${prefix}_protocol`, value: '{{order.protocol}}', type: 'Custom Value' as const },
28
+ ],
29
+ },
30
+ })
31
+
32
+ // Block A: Direct EnduserOrder creation path
33
+ const direct_order_creation_block = async ({ sdk }: { sdk: Session }) => {
34
+ log_header("Block A: Direct EnduserOrder creation -> {{order.*}} in Set Fields")
35
+
36
+ const enduser = await sdk.api.endusers.createOne({})
37
+ const trigger = await sdk.api.automation_triggers.createOne({
38
+ title: TRIGGER_TITLE_BLOCK_A,
39
+ status: 'Active',
40
+ event: { type: 'Order Status Equals', info: { source: 'Beluga', status: 'Shipped' } },
41
+ action: buildSetFieldsAction('pharmacy'),
42
+ })
43
+
44
+ let createdOrderId: string | undefined
45
+ let nonMatchingOrderId: string | undefined
46
+
47
+ try {
48
+ const order = await sdk.api.enduser_orders.createOne({
49
+ enduserId: enduser.id,
50
+ source: 'Beluga',
51
+ status: 'Shipped',
52
+ title: 'Beluga Pharmacy Order',
53
+ externalId: 'EXT-A-123',
54
+ tracking: '1Z-AAA',
55
+ carrier: 'UPS',
56
+ sku: 'SKU-A',
57
+ protocol: 'wl1',
58
+ })
59
+ createdOrderId = order.id
60
+
61
+ await wait(undefined, 250) // allow trigger + Set Fields to run
62
+
63
+ await async_test(
64
+ "Block A: {{order.*}} templates resolve to literal values",
65
+ () => sdk.api.endusers.getOne(enduser.id),
66
+ { onResult: e => !!(
67
+ e.fields?.pharmacy_status === 'Shipped'
68
+ && e.fields?.pharmacy_tracking === '1Z-AAA'
69
+ && e.fields?.pharmacy_carrier === 'UPS'
70
+ && e.fields?.pharmacy_sku === 'SKU-A'
71
+ && e.fields?.pharmacy_externalId === 'EXT-A-123'
72
+ && e.fields?.pharmacy_protocol === 'wl1'
73
+ && typeof e.fields?.pharmacy_id === 'string'
74
+ && (e.fields?.pharmacy_id as string).length > 0
75
+ && (e.fields?.pharmacy_id as string) === order.id
76
+ )}
77
+ )
78
+
79
+ // Negative case: status that doesn't match the trigger should NOT alter fields
80
+ const beforeNonMatch = await sdk.api.endusers.getOne(enduser.id)
81
+ const snapshot = {
82
+ pharmacy_status: beforeNonMatch.fields?.pharmacy_status,
83
+ pharmacy_tracking: beforeNonMatch.fields?.pharmacy_tracking,
84
+ pharmacy_carrier: beforeNonMatch.fields?.pharmacy_carrier,
85
+ pharmacy_sku: beforeNonMatch.fields?.pharmacy_sku,
86
+ pharmacy_externalId: beforeNonMatch.fields?.pharmacy_externalId,
87
+ pharmacy_id: beforeNonMatch.fields?.pharmacy_id,
88
+ pharmacy_protocol: beforeNonMatch.fields?.pharmacy_protocol,
89
+ }
90
+
91
+ const nonMatching = await sdk.api.enduser_orders.createOne({
92
+ enduserId: enduser.id,
93
+ source: 'Beluga',
94
+ status: 'In Fulfillment', // does NOT match the trigger
95
+ title: 'Beluga Pharmacy Order (other status)',
96
+ externalId: 'EXT-A-NOMATCH',
97
+ tracking: 'NOPE',
98
+ carrier: 'NoCarrier',
99
+ sku: 'SKU-NOPE',
100
+ protocol: 'nope',
101
+ })
102
+ nonMatchingOrderId = nonMatching.id
103
+
104
+ await wait(undefined, 250)
105
+
106
+ await async_test(
107
+ "Block A: non-matching status leaves fields unchanged",
108
+ () => sdk.api.endusers.getOne(enduser.id),
109
+ { onResult: e => !!(
110
+ e.fields?.pharmacy_status === snapshot.pharmacy_status
111
+ && e.fields?.pharmacy_tracking === snapshot.pharmacy_tracking
112
+ && e.fields?.pharmacy_carrier === snapshot.pharmacy_carrier
113
+ && e.fields?.pharmacy_sku === snapshot.pharmacy_sku
114
+ && e.fields?.pharmacy_externalId === snapshot.pharmacy_externalId
115
+ && e.fields?.pharmacy_id === snapshot.pharmacy_id
116
+ && e.fields?.pharmacy_protocol === snapshot.pharmacy_protocol
117
+ )}
118
+ )
119
+ } finally {
120
+ if (createdOrderId) await sdk.api.enduser_orders.deleteOne(createdOrderId).catch(console.error)
121
+ if (nonMatchingOrderId) await sdk.api.enduser_orders.deleteOne(nonMatchingOrderId).catch(console.error)
122
+ await sdk.api.automation_triggers.deleteOne(trigger.id).catch(console.error)
123
+ await sdk.api.endusers.deleteOne(enduser.id).catch(console.error)
124
+ }
125
+ }
126
+
127
+ // Block B: Beluga webhook integration path
128
+ const beluga_webhook_block = async ({ sdk }: { sdk: Session }) => {
129
+ log_header("Block B: Beluga webhook -> {{order.*}} in Set Fields")
130
+
131
+ const webhookUrl = `${host}/v1/webhooks/beluga`
132
+ const externalOrderId = `EXT-B-${Date.now()}`
133
+
134
+ const enduser = await sdk.api.endusers.createOne({})
135
+ const form = await sdk.api.forms.createOne({ title: 'Order Templates Beluga Form' })
136
+ const formResponse = await sdk.api.form_responses.createOne({
137
+ formId: form.id,
138
+ enduserId: enduser.id,
139
+ formTitle: form.title,
140
+ })
141
+
142
+ const trigger = await sdk.api.automation_triggers.createOne({
143
+ title: TRIGGER_TITLE_BLOCK_B,
144
+ status: 'Active',
145
+ event: { type: 'Order Status Equals', info: { source: 'Beluga', status: 'Shipped' } },
146
+ action: buildSetFieldsAction('pharmacy'),
147
+ })
148
+
149
+ const deliveredTrigger = await sdk.api.automation_triggers.createOne({
150
+ title: `${TRIGGER_TITLE_BLOCK_B} (Delivered)`,
151
+ status: 'Active',
152
+ event: { type: 'Order Status Equals', info: { source: 'Beluga', status: 'Delivered' } },
153
+ action: buildSetFieldsAction('pharmacy'),
154
+ })
155
+
156
+ try {
157
+ // Step 1: PHARMACY_ORDER_SHIPPED
158
+ const shippedRes = await fetch(webhookUrl, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ masterId: `tellescope_${formResponse.id}`,
163
+ event: 'PHARMACY_ORDER_SHIPPED',
164
+ orderId: externalOrderId,
165
+ info: { carrier: 'FedEx', tracking: '7777-BBB' },
166
+ }),
167
+ })
168
+ assert(shippedRes.status === 200, `Beluga webhook (shipped) expected 200, got ${shippedRes.status}`)
169
+
170
+ await wait(undefined, 250) // webhook upsert + trigger + Set Fields
171
+
172
+ await async_test(
173
+ "Block B: Beluga PHARMACY_ORDER_SHIPPED resolves {{order.*}} into enduser fields",
174
+ () => sdk.api.endusers.getOne(enduser.id),
175
+ { onResult: e => !!(
176
+ e.fields?.pharmacy_status === 'Shipped'
177
+ && e.fields?.pharmacy_tracking === '7777-BBB'
178
+ && e.fields?.pharmacy_carrier === 'FedEx'
179
+ && e.fields?.pharmacy_externalId === externalOrderId
180
+ && typeof e.fields?.pharmacy_id === 'string'
181
+ && (e.fields?.pharmacy_id as string).length > 0
182
+ // Webhook does not set sku/protocol -> default branch in helper -> ''
183
+ && e.fields?.pharmacy_sku === ''
184
+ && e.fields?.pharmacy_protocol === ''
185
+ )}
186
+ )
187
+
188
+ // Step 2: PHARMACY_ORDER_DELIVERED for the same order
189
+ const deliveredRes = await fetch(webhookUrl, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({
193
+ masterId: `tellescope_${formResponse.id}`,
194
+ event: 'PHARMACY_ORDER_DELIVERED',
195
+ orderId: externalOrderId,
196
+ }),
197
+ })
198
+ assert(deliveredRes.status === 200, `Beluga webhook (delivered) expected 200, got ${deliveredRes.status}`)
199
+
200
+ await wait(undefined, 250)
201
+
202
+ await async_test(
203
+ "Block B: PHARMACY_ORDER_DELIVERED flips pharmacy_status to 'Delivered'",
204
+ () => sdk.api.endusers.getOne(enduser.id),
205
+ { onResult: e => !!(
206
+ e.fields?.pharmacy_status === 'Delivered'
207
+ && e.fields?.pharmacy_externalId === externalOrderId
208
+ )}
209
+ )
210
+ } finally {
211
+ // Clean up the order created by the webhook
212
+ try {
213
+ const orders = await sdk.api.enduser_orders.getSome({
214
+ filter: { source: 'Beluga', externalId: externalOrderId } as any,
215
+ })
216
+ for (const o of orders) {
217
+ await sdk.api.enduser_orders.deleteOne(o.id).catch(console.error)
218
+ }
219
+ } catch (err) {
220
+ console.error(err)
221
+ }
222
+
223
+ await sdk.api.automation_triggers.deleteOne(trigger.id).catch(console.error)
224
+ await sdk.api.automation_triggers.deleteOne(deliveredTrigger.id).catch(console.error)
225
+ await sdk.api.form_responses.deleteOne(formResponse.id).catch(console.error)
226
+ await sdk.api.forms.deleteOne(form.id).catch(console.error)
227
+ await sdk.api.endusers.deleteOne(enduser.id).catch(console.error)
228
+ }
229
+ }
230
+
231
+ export const set_fields_order_templates_tests = async (
232
+ { sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
233
+ ) => {
234
+ log_header("Set Fields: {{order.*}} template resolution")
235
+ await direct_order_creation_block({ sdk })
236
+ await beluga_webhook_block({ sdk })
237
+ }
238
+
239
+ if (require.main === module) {
240
+ console.log(`Using API URL: ${host}`)
241
+ const sdk = new Session({ host })
242
+ const sdkNonAdmin = new Session({ host })
243
+
244
+ const runTests = async () => {
245
+ await setup_tests(sdk, sdkNonAdmin)
246
+ await set_fields_order_templates_tests({ sdk, sdkNonAdmin })
247
+ }
248
+
249
+ runTests()
250
+ .then(() => {
251
+ console.log("set_fields_order_templates test suite completed successfully")
252
+ process.exit(0)
253
+ })
254
+ .catch((error) => {
255
+ console.error("set_fields_order_templates test suite failed:", error)
256
+ process.exit(1)
257
+ })
258
+ }
@@ -37,9 +37,16 @@ export const setup_tests = async (sdk: Session, sdkNonAdmin: Session) => {
37
37
  // Authenticate the SDKs first
38
38
  await sdk.authenticate(email, password)
39
39
  await sdkNonAdmin.authenticate(nonAdminEmail, nonAdminPassword)
40
-
40
+
41
41
  await async_test('test_authenticated', sdk.test_authenticated, { expectedResult: 'Authenticated!' })
42
42
 
43
+ // Defensive: clear residual OTP gating from a previously-aborted run of
44
+ // enduser_session_invalidation_tests, which would otherwise cause enduser
45
+ // tokens minted in subsequent tests to be rejected as Unauthenticated.
46
+ await sdk.api.organizations.updateOne(sdk.userInfo.businessId, {
47
+ portalSettings: { authentication: { requireOTP: false, requireOTPAfterPassword: false } },
48
+ }, { replaceObjectFields: true })
49
+
43
50
  await async_test(
44
51
  'test_authenticated (with API Key)',
45
52
  (new Session({ host, apiKey: '3n5q0SCBT_iUvZz-b9BJtX7o7HQUVJ9v132PgHJNJsg.' /* local test key */ })).test_authenticated,