@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.
- package/lib/cjs/sdk.d.ts +9 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +3 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
- package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +370 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +47 -32
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +179 -158
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +9 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +3 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
- package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +366 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +47 -32
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +179 -158
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +12 -0
- package/src/tests/api_tests/account_switcher.test.ts +1283 -0
- package/src/tests/api_tests/enduser_login.test.ts +215 -0
- package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +198 -0
- package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
- package/src/tests/setup.ts +8 -1
- package/src/tests/tests.ts +18 -5
- 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
|
+
}
|
package/src/tests/setup.ts
CHANGED
|
@@ -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,
|