@tellescope/sdk 1.254.0 → 1.255.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 +4 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +5 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js +187 -0
- package/lib/cjs/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +443 -0
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js +254 -0
- package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/organization_api_keys.test.js +226 -0
- package/lib/cjs/tests/api_tests/organization_api_keys.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +362 -0
- package/lib/cjs/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js +139 -0
- package/lib/cjs/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +156 -0
- package/lib/cjs/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js +157 -0
- package/lib/cjs/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +20 -1
- 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 +543 -282
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +6 -2
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +5 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +1 -0
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts +16 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js +183 -0
- package/lib/esm/tests/api_tests/ai_decision_summary_config.test.js.map +1 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +439 -0
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -0
- package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js +250 -0
- package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
- package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/organization_api_keys.test.js +222 -0
- package/lib/esm/tests/api_tests/organization_api_keys.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts +21 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js +358 -0
- package/lib/esm/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js +135 -0
- package/lib/esm/tests/api_tests/security/F-0145-globalunique-leak.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts +18 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js +152 -0
- package/lib/esm/tests/api_tests/security/F-0151-load-inbox-redaction.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts +20 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js +150 -0
- package/lib/esm/tests/api_tests/security/F-0155-webhook-timeout.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +20 -1
- 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 +543 -282
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +11 -0
- package/src/tests/api_tests/mdi_case_offerings.test.ts +414 -0
- package/src/tests/api_tests/organization_api_keys.test.ts +207 -0
- package/src/tests/api_tests/security/F-0116-F-0119-enduser-cross-create.test.ts +221 -0
- package/src/tests/api_tests/security/F-0145-globalunique-leak.test.ts +78 -0
- package/src/tests/api_tests/security/F-0151-load-inbox-redaction.test.ts +81 -0
- package/src/tests/api_tests/security/F-0155-webhook-timeout.test.ts +85 -0
- package/src/tests/setup.ts +9 -0
- package/src/tests/tests.ts +199 -2
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
log_header,
|
|
7
|
+
} from "@tellescope/testing"
|
|
8
|
+
import { setup_tests } from "../setup"
|
|
9
|
+
import { evaluate_conditional_logic, evaluate_string_field_comparison } from "@tellescope/utilities"
|
|
10
|
+
import { CompoundFilter } from "@tellescope/types-models"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
type MDIOffering = { offering_id: string, conditions?: CompoundFilter<string> }
|
|
15
|
+
|
|
16
|
+
// Local replica of the API's resolve_case_offerings (md_integrations.ts), built
|
|
17
|
+
// from the same shared @tellescope/utilities helpers it uses, so we can assert
|
|
18
|
+
// the resolution behavior from the SDK test harness.
|
|
19
|
+
const is_empty_conditions = (conditions: CompoundFilter<string>): boolean => {
|
|
20
|
+
if (!conditions || !Object.keys(conditions).length) return true
|
|
21
|
+
const condition = (conditions as { condition?: Record<string, unknown> }).condition
|
|
22
|
+
if (condition !== undefined) return !Object.keys(condition).length
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resolve_case_offerings = (
|
|
27
|
+
offerings: MDIOffering[],
|
|
28
|
+
responses: { externalId?: string, value?: string }[],
|
|
29
|
+
): MDIOffering[] => (
|
|
30
|
+
offerings.filter(o => {
|
|
31
|
+
if (!o.conditions || is_empty_conditions(o.conditions)) return true
|
|
32
|
+
return evaluate_conditional_logic(o.conditions, (key, value) =>
|
|
33
|
+
evaluate_string_field_comparison(responses.find(r => r.externalId === key)?.value, value)
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Local replicas of the API's patient-mapping helpers (md_integrations.ts).
|
|
39
|
+
// SDK tests cannot import the private API package, so we mirror the conversion
|
|
40
|
+
// helpers + exclusion set here to assert the same behavior.
|
|
41
|
+
type GenericQuantityWithUnit = { value: number | string, unit: string }
|
|
42
|
+
|
|
43
|
+
const to_number = (v: unknown): number | undefined => {
|
|
44
|
+
const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''))
|
|
45
|
+
return Number.isFinite(n) ? n : undefined
|
|
46
|
+
}
|
|
47
|
+
const enduser_height_to_cm = (h?: GenericQuantityWithUnit): number | undefined => {
|
|
48
|
+
const inches = to_number(h?.value); if (inches === undefined) return undefined
|
|
49
|
+
return Math.round(inches * 2.54)
|
|
50
|
+
}
|
|
51
|
+
const enduser_weight_to_kg = (w?: GenericQuantityWithUnit): number | undefined => {
|
|
52
|
+
const lbs = to_number(w?.value); if (lbs === undefined) return undefined
|
|
53
|
+
return Math.round(lbs * 0.45359237 * 100) / 100
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PATIENT_MAPPED_EXTERNAL_IDS = new Set([
|
|
57
|
+
'fname', 'first_name', 'lname', 'last_name',
|
|
58
|
+
'email', 'dateOfBirth', 'gender', 'phone', 'address',
|
|
59
|
+
'height', 'weight',
|
|
60
|
+
'allergies', 'current_medications', 'medical_conditions', 'special_necessities', 'pregnancy',
|
|
61
|
+
'intro_video_id',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
// Mirrors the externalId exclusion step in form_response_to_case_questions:
|
|
65
|
+
// patient-mapped fields are dropped, everything else survives.
|
|
66
|
+
const case_question_external_ids = (responses: { externalId?: string }[]): string[] => (
|
|
67
|
+
responses
|
|
68
|
+
.filter(r => !(r.externalId && PATIENT_MAPPED_EXTERNAL_IDS.has(r.externalId)))
|
|
69
|
+
.map(r => r.externalId)
|
|
70
|
+
.filter((id): id is string => id !== undefined)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Local replica of form_response_to_case_questions' displayed_options logic
|
|
74
|
+
// (md_integrations.ts). SDK tests cannot import the private API package, so we
|
|
75
|
+
// mirror the field lookup (_id first, externalId fallback) + the multiple_choice
|
|
76
|
+
// / Dropdown gate, and assert displayed_options is populated only when applicable.
|
|
77
|
+
type ReplicaField = { _id: string, externalId?: string, options?: { choices?: string[] } }
|
|
78
|
+
type ReplicaResponse = { fieldId?: string, externalId?: string, answer: { type?: string } }
|
|
79
|
+
|
|
80
|
+
const resolve_displayed_options = (
|
|
81
|
+
response: ReplicaResponse,
|
|
82
|
+
formFields: ReplicaField[] = [],
|
|
83
|
+
): string[] | undefined => {
|
|
84
|
+
const field = (
|
|
85
|
+
formFields.find(f => f._id.toString() === response.fieldId)
|
|
86
|
+
|| formFields.find(f => response.externalId && f.externalId === response.externalId)
|
|
87
|
+
)
|
|
88
|
+
return (
|
|
89
|
+
(response.answer.type === 'multiple_choice' || response.answer.type === 'Dropdown')
|
|
90
|
+
&& field?.options?.choices?.length
|
|
91
|
+
) ? field.options.choices : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const mdi_case_offerings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
|
|
95
|
+
log_header("MDI Case Offerings Tests")
|
|
96
|
+
|
|
97
|
+
let testFormId: string | undefined
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// --- Schema round-trip tests ---
|
|
101
|
+
|
|
102
|
+
await async_test(
|
|
103
|
+
"Create form with mdiCaseOfferings mixing unconditional and conditional entries",
|
|
104
|
+
async () => {
|
|
105
|
+
const offerings: MDIOffering[] = [
|
|
106
|
+
{ offering_id: "offering-always" }, // unconditional
|
|
107
|
+
{ offering_id: "offering-ca", conditions: { condition: { state: "CA" } } },
|
|
108
|
+
{
|
|
109
|
+
offering_id: "offering-ca-glp",
|
|
110
|
+
conditions: { $and: [{ condition: { state: "CA" } }, { condition: { med_type: "weightLoss" } }] },
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const form = await sdk.api.forms.createOne({
|
|
115
|
+
title: 'MDI Case Offerings Test Form',
|
|
116
|
+
mdiCaseOfferings: offerings,
|
|
117
|
+
})
|
|
118
|
+
testFormId = form.id
|
|
119
|
+
|
|
120
|
+
const fetched = await sdk.api.forms.getOne(form.id)
|
|
121
|
+
return (
|
|
122
|
+
fetched.mdiCaseOfferings?.length === 3
|
|
123
|
+
&& fetched.mdiCaseOfferings[0].offering_id === "offering-always"
|
|
124
|
+
&& fetched.mdiCaseOfferings[0].conditions === undefined
|
|
125
|
+
&& !!fetched.mdiCaseOfferings[1].conditions?.condition
|
|
126
|
+
&& !!fetched.mdiCaseOfferings[2].conditions?.$and
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
{ expectedResult: true }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await async_test(
|
|
133
|
+
"Backwards-compat: existing flat { offering_id } config still validates",
|
|
134
|
+
async () => {
|
|
135
|
+
if (!testFormId) throw new Error("No test form")
|
|
136
|
+
|
|
137
|
+
// mirrors a pre-existing config with no conditions
|
|
138
|
+
const flat: MDIOffering[] = [
|
|
139
|
+
{ offering_id: "legacy-1" },
|
|
140
|
+
{ offering_id: "legacy-2" },
|
|
141
|
+
]
|
|
142
|
+
await sdk.api.forms.updateOne(testFormId, { mdiCaseOfferings: flat }, { replaceObjectFields: true })
|
|
143
|
+
const fetched = await sdk.api.forms.getOne(testFormId)
|
|
144
|
+
return (
|
|
145
|
+
fetched.mdiCaseOfferings?.length === 2
|
|
146
|
+
&& fetched.mdiCaseOfferings.every(o => o.conditions === undefined)
|
|
147
|
+
)
|
|
148
|
+
},
|
|
149
|
+
{ expectedResult: true }
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
await async_test(
|
|
153
|
+
"Save nested CompoundFilter conditions on an offering and read back",
|
|
154
|
+
async () => {
|
|
155
|
+
if (!testFormId) throw new Error("No test form")
|
|
156
|
+
|
|
157
|
+
const nested: MDIOffering[] = [
|
|
158
|
+
{
|
|
159
|
+
offering_id: "offering-nested",
|
|
160
|
+
conditions: {
|
|
161
|
+
$and: [
|
|
162
|
+
{ $or: [{ condition: { state: "CA" } }, { condition: { state: "NY" } }] },
|
|
163
|
+
{ condition: { med_type: "weightLoss" } },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
await sdk.api.forms.updateOne(testFormId, { mdiCaseOfferings: nested }, { replaceObjectFields: true })
|
|
169
|
+
const fetched = await sdk.api.forms.getOne(testFormId)
|
|
170
|
+
return (
|
|
171
|
+
fetched.mdiCaseOfferings?.length === 1
|
|
172
|
+
&& !!fetched.mdiCaseOfferings[0].conditions?.$and
|
|
173
|
+
)
|
|
174
|
+
},
|
|
175
|
+
{ expectedResult: true }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// --- resolve_case_offerings behavior tests ---
|
|
179
|
+
|
|
180
|
+
const MIXED: MDIOffering[] = [
|
|
181
|
+
{ offering_id: "always" }, // unconditional → always sent
|
|
182
|
+
{ offering_id: "ca-only", conditions: { condition: { state: "CA" } } },
|
|
183
|
+
{ offering_id: "ny-only", conditions: { condition: { state: "NY" } } },
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
await async_test(
|
|
187
|
+
"Resolver returns exactly the matching subset plus unconditional offerings",
|
|
188
|
+
async () => {
|
|
189
|
+
const resolved = resolve_case_offerings(MIXED, [{ externalId: "state", value: "CA" }])
|
|
190
|
+
return (
|
|
191
|
+
resolved.length === 2
|
|
192
|
+
&& resolved.some(o => o.offering_id === "always")
|
|
193
|
+
&& resolved.some(o => o.offering_id === "ca-only")
|
|
194
|
+
&& !resolved.some(o => o.offering_id === "ny-only")
|
|
195
|
+
)
|
|
196
|
+
},
|
|
197
|
+
{ expectedResult: true }
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await async_test(
|
|
201
|
+
"Send none: when conditions match nothing, only unconditional offerings remain",
|
|
202
|
+
async () => {
|
|
203
|
+
// a config where every offering carries a condition that fails
|
|
204
|
+
const allConditional: MDIOffering[] = [
|
|
205
|
+
{ offering_id: "ca-only", conditions: { condition: { state: "CA" } } },
|
|
206
|
+
{ offering_id: "ny-only", conditions: { condition: { state: "NY" } } },
|
|
207
|
+
]
|
|
208
|
+
const resolved = resolve_case_offerings(allConditional, [{ externalId: "state", value: "TX" }])
|
|
209
|
+
return resolved.length === 0
|
|
210
|
+
},
|
|
211
|
+
{ expectedResult: true }
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await async_test(
|
|
215
|
+
"Backwards compat: all-unconditional config sends everything",
|
|
216
|
+
async () => {
|
|
217
|
+
const allUnconditional: MDIOffering[] = [
|
|
218
|
+
{ offering_id: "a" },
|
|
219
|
+
{ offering_id: "b" },
|
|
220
|
+
{ offering_id: "c" },
|
|
221
|
+
]
|
|
222
|
+
const resolved = resolve_case_offerings(allUnconditional, [{ externalId: "state", value: "TX" }])
|
|
223
|
+
return resolved.length === 3
|
|
224
|
+
},
|
|
225
|
+
{ expectedResult: true }
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
await async_test(
|
|
229
|
+
"Empty/blank conditions object is treated as unconditional (always sent)",
|
|
230
|
+
async () => {
|
|
231
|
+
const withBlank: MDIOffering[] = [
|
|
232
|
+
{ offering_id: "blank", conditions: { condition: {} } },
|
|
233
|
+
]
|
|
234
|
+
// even with no matching response, the blank-condition offering is sent
|
|
235
|
+
const resolved = resolve_case_offerings(withBlank, [])
|
|
236
|
+
return resolved.length === 1 && resolved[0].offering_id === "blank"
|
|
237
|
+
},
|
|
238
|
+
{ expectedResult: true }
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
await async_test(
|
|
242
|
+
"$or conditions: offering sent when any branch matches",
|
|
243
|
+
async () => {
|
|
244
|
+
const offerings: MDIOffering[] = [
|
|
245
|
+
{ offering_id: "ca-or-ny", conditions: { $or: [{ condition: { state: "CA" } }, { condition: { state: "NY" } }] } },
|
|
246
|
+
]
|
|
247
|
+
const resolved = resolve_case_offerings(offerings, [{ externalId: "state", value: "NY" }])
|
|
248
|
+
return resolved.length === 1
|
|
249
|
+
},
|
|
250
|
+
{ expectedResult: true }
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await async_test(
|
|
254
|
+
"$contains operator narrows offerings by substring match",
|
|
255
|
+
async () => {
|
|
256
|
+
const offerings: MDIOffering[] = [
|
|
257
|
+
{ offering_id: "glp", conditions: { condition: { meds: { $contains: "GLP" } as any } } },
|
|
258
|
+
]
|
|
259
|
+
const matches = resolve_case_offerings(offerings, [{ externalId: "meds", value: "GLP-1 agonist" }])
|
|
260
|
+
const misses = resolve_case_offerings(offerings, [{ externalId: "meds", value: "Semaglutide" }])
|
|
261
|
+
return matches.length === 1 && misses.length === 0
|
|
262
|
+
},
|
|
263
|
+
{ expectedResult: true }
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// --- height/weight conversion tests ---
|
|
267
|
+
|
|
268
|
+
await async_test(
|
|
269
|
+
"enduser_height_to_cm converts Inches → integer centimeters",
|
|
270
|
+
async () => (
|
|
271
|
+
enduser_height_to_cm({ value: 70, unit: 'Inches' }) === 178
|
|
272
|
+
&& enduser_height_to_cm({ value: '70', unit: 'Inches' }) === 178 // string value parsed
|
|
273
|
+
&& enduser_height_to_cm(undefined) === undefined // absent → omitted
|
|
274
|
+
&& enduser_height_to_cm({ value: '', unit: 'Inches' }) === undefined
|
|
275
|
+
),
|
|
276
|
+
{ expectedResult: true }
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
await async_test(
|
|
280
|
+
"enduser_weight_to_kg converts Pounds → kilograms (float, 2dp)",
|
|
281
|
+
async () => (
|
|
282
|
+
enduser_weight_to_kg({ value: 200, unit: 'Pounds' }) === 90.72
|
|
283
|
+
&& enduser_weight_to_kg({ value: '200', unit: 'Pounds' }) === 90.72 // string value parsed
|
|
284
|
+
&& enduser_weight_to_kg(undefined) === undefined // absent → omitted
|
|
285
|
+
&& enduser_weight_to_kg({ value: 'not-a-number', unit: 'Pounds' }) === undefined
|
|
286
|
+
),
|
|
287
|
+
{ expectedResult: true }
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
// --- case_questions exclusion tests ---
|
|
291
|
+
|
|
292
|
+
await async_test(
|
|
293
|
+
"case_questions exclude patient-mapped fields but keep non-mapped clinical answers",
|
|
294
|
+
async () => {
|
|
295
|
+
const responses = [
|
|
296
|
+
{ externalId: 'fname' },
|
|
297
|
+
{ externalId: 'email' },
|
|
298
|
+
{ externalId: 'dateOfBirth' },
|
|
299
|
+
{ externalId: 'gender' },
|
|
300
|
+
{ externalId: 'phone' },
|
|
301
|
+
{ externalId: 'address' },
|
|
302
|
+
{ externalId: 'height' },
|
|
303
|
+
{ externalId: 'weight' },
|
|
304
|
+
{ externalId: 'allergies' },
|
|
305
|
+
{ externalId: 'special_necessities' },
|
|
306
|
+
{ externalId: 'symptoms' }, // non-mapped clinical field → survives
|
|
307
|
+
{ externalId: 'duration' }, // non-mapped clinical field → survives
|
|
308
|
+
]
|
|
309
|
+
const kept = case_question_external_ids(responses)
|
|
310
|
+
const excluded = ['fname', 'email', 'dateOfBirth', 'gender', 'phone', 'address', 'height', 'weight', 'allergies', 'special_necessities']
|
|
311
|
+
return (
|
|
312
|
+
kept.length === 2
|
|
313
|
+
&& kept.includes('symptoms')
|
|
314
|
+
&& kept.includes('duration')
|
|
315
|
+
&& excluded.every(id => !kept.includes(id))
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
{ expectedResult: true }
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
// --- displayed_options tests ---
|
|
322
|
+
|
|
323
|
+
await async_test(
|
|
324
|
+
"multiple_choice response + matching field by _id → displayed_options equals field choices",
|
|
325
|
+
async () => {
|
|
326
|
+
const fields: ReplicaField[] = [
|
|
327
|
+
{ _id: 'field-1', options: { choices: ['Yes', 'No', 'Maybe'] } },
|
|
328
|
+
]
|
|
329
|
+
const response: ReplicaResponse = { fieldId: 'field-1', answer: { type: 'multiple_choice' } }
|
|
330
|
+
const opts = resolve_displayed_options(response, fields)
|
|
331
|
+
return (
|
|
332
|
+
!!opts
|
|
333
|
+
&& opts.length === 3
|
|
334
|
+
&& opts[0] === 'Yes' && opts[1] === 'No' && opts[2] === 'Maybe'
|
|
335
|
+
)
|
|
336
|
+
},
|
|
337
|
+
{ expectedResult: true }
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await async_test(
|
|
341
|
+
"Dropdown response + matching field by externalId → displayed_options equals field choices",
|
|
342
|
+
async () => {
|
|
343
|
+
const fields: ReplicaField[] = [
|
|
344
|
+
{ _id: 'field-2', externalId: 'state', options: { choices: ['CA', 'NY', 'TX'] } },
|
|
345
|
+
]
|
|
346
|
+
// fieldId does not match any field; externalId fallback resolves the field
|
|
347
|
+
const response: ReplicaResponse = { fieldId: '', externalId: 'state', answer: { type: 'Dropdown' } }
|
|
348
|
+
const opts = resolve_displayed_options(response, fields)
|
|
349
|
+
return !!opts && opts.length === 3 && opts.join(',') === 'CA,NY,TX'
|
|
350
|
+
},
|
|
351
|
+
{ expectedResult: true }
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
await async_test(
|
|
355
|
+
"string/number response → displayed_options undefined even with field choices present",
|
|
356
|
+
async () => {
|
|
357
|
+
const fields: ReplicaField[] = [
|
|
358
|
+
{ _id: 'field-3', options: { choices: ['Yes', 'No'] } },
|
|
359
|
+
]
|
|
360
|
+
const stringResponse: ReplicaResponse = { fieldId: 'field-3', answer: { type: 'string' } }
|
|
361
|
+
const numberResponse: ReplicaResponse = { fieldId: 'field-3', answer: { type: 'number' } }
|
|
362
|
+
return (
|
|
363
|
+
resolve_displayed_options(stringResponse, fields) === undefined
|
|
364
|
+
&& resolve_displayed_options(numberResponse, fields) === undefined
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
{ expectedResult: true }
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
await async_test(
|
|
371
|
+
"multiple_choice response with no matching field → displayed_options undefined (question still kept)",
|
|
372
|
+
async () => {
|
|
373
|
+
const fields: ReplicaField[] = [
|
|
374
|
+
{ _id: 'field-4', externalId: 'other', options: { choices: ['A', 'B'] } },
|
|
375
|
+
]
|
|
376
|
+
// neither fieldId nor externalId matches any field
|
|
377
|
+
const noMatch: ReplicaResponse = { fieldId: 'field-unknown', externalId: 'unknown', answer: { type: 'multiple_choice' } }
|
|
378
|
+
// matching field but empty choices → also undefined
|
|
379
|
+
const emptyChoices: ReplicaResponse = { fieldId: 'field-5', answer: { type: 'multiple_choice' } }
|
|
380
|
+
const fieldsEmpty: ReplicaField[] = [{ _id: 'field-5', options: { choices: [] } }]
|
|
381
|
+
return (
|
|
382
|
+
resolve_displayed_options(noMatch, fields) === undefined
|
|
383
|
+
&& resolve_displayed_options(emptyChoices, fieldsEmpty) === undefined
|
|
384
|
+
)
|
|
385
|
+
},
|
|
386
|
+
{ expectedResult: true }
|
|
387
|
+
)
|
|
388
|
+
} finally {
|
|
389
|
+
if (testFormId) {
|
|
390
|
+
await sdk.api.forms.deleteOne(testFormId).catch(console.error)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (require.main === module) {
|
|
396
|
+
console.log(`Using API URL: ${host}`)
|
|
397
|
+
const sdk = new Session({ host })
|
|
398
|
+
const sdkNonAdmin = new Session({ host })
|
|
399
|
+
|
|
400
|
+
const runTests = async () => {
|
|
401
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
402
|
+
await mdi_case_offerings_tests({ sdk, sdkNonAdmin })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
runTests()
|
|
406
|
+
.then(() => {
|
|
407
|
+
console.log("MDI case offerings test suite completed successfully")
|
|
408
|
+
process.exit(0)
|
|
409
|
+
})
|
|
410
|
+
.catch((error) => {
|
|
411
|
+
console.error("MDI case offerings test suite failed:", error)
|
|
412
|
+
process.exit(1)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
// fields that the admin org-wide list endpoint is allowed to return
|
|
15
|
+
const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt'].sort()
|
|
16
|
+
|
|
17
|
+
export const organization_api_keys_tests = async (
|
|
18
|
+
{ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
|
|
19
|
+
) => {
|
|
20
|
+
log_header("Organization API Keys (admin org-wide management)")
|
|
21
|
+
|
|
22
|
+
// We never mutate sdkNonAdmin's roles here (throwaway-user rule). Instead we create a
|
|
23
|
+
// dedicated throwaway user in the SAME org that DOES have api_keys access (via a custom
|
|
24
|
+
// role), so it can create its own key (the offboarded-employee scenario) and so the
|
|
25
|
+
// admin-gating negative tests below exercise the adminOnly gate (401) rather than the
|
|
26
|
+
// access-permission gate (403) — a non-admin without api_keys access is rejected with
|
|
27
|
+
// "Inaccessible" before adminOnly is ever reached.
|
|
28
|
+
const adminKeyIds: string[] = []
|
|
29
|
+
let otherKeyId: string | undefined
|
|
30
|
+
let otherUserId: string | undefined
|
|
31
|
+
let apiKeysRoleId: string | undefined
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// A custom role that grants api_keys access (merged over PROVIDER_PERMISSIONS server-side)
|
|
35
|
+
const apiKeysRole = await sdk.api.role_based_access_permissions.createOne({
|
|
36
|
+
role: 'api-keys-test-role',
|
|
37
|
+
permissions: { api_keys: { create: 'All', read: 'All', update: 'All', delete: 'All' } },
|
|
38
|
+
})
|
|
39
|
+
apiKeysRoleId = apiKeysRole.id
|
|
40
|
+
|
|
41
|
+
// A throwaway non-admin user in the same org, holding that role
|
|
42
|
+
const otherUser = (
|
|
43
|
+
await sdk.api.users.getOne({ email: 'api.keys.other.user@tellescope.com' }).catch(() => null)
|
|
44
|
+
) || (
|
|
45
|
+
await sdk.api.users.createOne({ email: 'api.keys.other.user@tellescope.com', notificationEmailsDisabled: true, verifiedEmail: true })
|
|
46
|
+
)
|
|
47
|
+
otherUserId = otherUser.id
|
|
48
|
+
await sdk.api.users.updateOne(otherUser.id, { roles: [apiKeysRole.role] }, { replaceObjectFields: true })
|
|
49
|
+
await wait(undefined, 2000) // wait for the role change to propagate before authenticating
|
|
50
|
+
|
|
51
|
+
const otherSdk = new Session({
|
|
52
|
+
host,
|
|
53
|
+
authToken: (await sdk.api.users.generate_auth_token({ id: otherUser.id })).authToken,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// A key created by the admin...
|
|
57
|
+
const adminKey = await sdk.api.api_keys.createOne({})
|
|
58
|
+
adminKeyIds.push(adminKey.id)
|
|
59
|
+
|
|
60
|
+
// ...and a key created by ANOTHER user in the org (the offboarded-employee scenario)
|
|
61
|
+
const otherKey = await otherSdk.api.api_keys.createOne({})
|
|
62
|
+
otherKeyId = otherKey.id
|
|
63
|
+
|
|
64
|
+
// =============================================
|
|
65
|
+
// CORE: admin sees keys created by other users
|
|
66
|
+
// =============================================
|
|
67
|
+
const { apiKeys } = await sdk.api.api_keys.get_organization_api_keys()
|
|
68
|
+
|
|
69
|
+
assert(
|
|
70
|
+
apiKeys.some(k => k.id === otherKeyId),
|
|
71
|
+
`admin list did not include another user's key ${otherKeyId}`,
|
|
72
|
+
"admin get_organization_api_keys returns keys created by other users in the org"
|
|
73
|
+
)
|
|
74
|
+
assert(
|
|
75
|
+
apiKeys.some(k => k.id === adminKey.id),
|
|
76
|
+
"admin list did not include the admin's own key",
|
|
77
|
+
"admin get_organization_api_keys returns the admin's own keys"
|
|
78
|
+
)
|
|
79
|
+
assert(
|
|
80
|
+
!!apiKeys.find(k => k.id === otherKeyId && k.creator === otherUser.id),
|
|
81
|
+
"creator on the other user's key did not match",
|
|
82
|
+
"returned key reports the correct creator"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// =============================================
|
|
86
|
+
// SECRET REDACTION (server-side, allowlist)
|
|
87
|
+
// =============================================
|
|
88
|
+
assert(
|
|
89
|
+
apiKeys.every(k => (k as any).key === undefined),
|
|
90
|
+
"a returned record exposed a plaintext `key`",
|
|
91
|
+
"no returned record exposes a plaintext key (legacy field)"
|
|
92
|
+
)
|
|
93
|
+
assert(
|
|
94
|
+
apiKeys.every(k => (k as any).hashedKey === undefined),
|
|
95
|
+
"a returned record exposed `hashedKey`",
|
|
96
|
+
"no returned record exposes hashedKey"
|
|
97
|
+
)
|
|
98
|
+
// Strict allowlist: every returned field must be in the allowlist (no extra/sensitive field
|
|
99
|
+
// ever leaks). We can't require all allowlisted fields to be PRESENT — never-updated keys
|
|
100
|
+
// have no `updatedAt`, and res.json() drops undefined values — so this is a subset check.
|
|
101
|
+
const offending = apiKeys
|
|
102
|
+
.map(k => Object.keys(k).filter(key => !ALLOWED_FIELDS.includes(key)))
|
|
103
|
+
.find(extra => extra.length > 0)
|
|
104
|
+
assert(
|
|
105
|
+
offending === undefined,
|
|
106
|
+
`a returned record exposed non-allowlisted fields: ${JSON.stringify(offending)}`,
|
|
107
|
+
"returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// =============================================
|
|
111
|
+
// CROSS-ORG ISOLATION (no leakage across businesses)
|
|
112
|
+
// =============================================
|
|
113
|
+
assert(
|
|
114
|
+
apiKeys.every(k => k.businessId === sdk.userInfo.businessId),
|
|
115
|
+
"a returned key belonged to a different business",
|
|
116
|
+
"all returned keys are scoped to the admin's businessId"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// =============================================
|
|
120
|
+
// ADMIN GATING (negative tests)
|
|
121
|
+
// =============================================
|
|
122
|
+
await async_test(
|
|
123
|
+
"non-admin get_organization_api_keys is rejected (401)",
|
|
124
|
+
() => otherSdk.api.api_keys.get_organization_api_keys(),
|
|
125
|
+
{ shouldError: true, onError: (e: any) => e.statusCode === 401 || /admin access only/i.test(e.message || '') }
|
|
126
|
+
)
|
|
127
|
+
await async_test(
|
|
128
|
+
"non-admin delete_organization_api_key is rejected (401)",
|
|
129
|
+
() => otherSdk.api.api_keys.delete_organization_api_key({ id: adminKey.id }),
|
|
130
|
+
{ shouldError: true, onError: (e: any) => e.statusCode === 401 || /admin access only/i.test(e.message || '') }
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// confirm the gating did not delete the key
|
|
134
|
+
const afterGating = await sdk.api.api_keys.get_organization_api_keys()
|
|
135
|
+
assert(
|
|
136
|
+
afterGating.apiKeys.some(k => k.id === adminKey.id),
|
|
137
|
+
"rejected non-admin delete still removed the key",
|
|
138
|
+
"rejected non-admin delete left the key intact"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// =============================================
|
|
142
|
+
// ADMIN DELETE another user's key
|
|
143
|
+
// =============================================
|
|
144
|
+
await async_test(
|
|
145
|
+
"admin delete_organization_api_key deletes another user's key",
|
|
146
|
+
() => sdk.api.api_keys.delete_organization_api_key({ id: otherKeyId! }),
|
|
147
|
+
{ shouldError: false, onResult: () => true }
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const afterDelete = await sdk.api.api_keys.get_organization_api_keys()
|
|
151
|
+
assert(
|
|
152
|
+
!afterDelete.apiKeys.some(k => k.id === otherKeyId),
|
|
153
|
+
"deleted key still present in org-wide list",
|
|
154
|
+
"deleted key no longer appears in the org-wide list"
|
|
155
|
+
)
|
|
156
|
+
otherKeyId = undefined // successfully deleted; nothing to clean up
|
|
157
|
+
|
|
158
|
+
// deleting a key in a different org (fabricated id) must be a no-op for our org
|
|
159
|
+
await async_test(
|
|
160
|
+
"admin delete of a non-existent/cross-org key id is a safe no-op",
|
|
161
|
+
() => sdk.api.api_keys.delete_organization_api_key({ id: '000000000000000000000000' }),
|
|
162
|
+
{ shouldError: false, onResult: () => true }
|
|
163
|
+
)
|
|
164
|
+
const afterNoop = await sdk.api.api_keys.get_organization_api_keys()
|
|
165
|
+
assert(
|
|
166
|
+
afterNoop.apiKeys.some(k => k.id === adminKey.id),
|
|
167
|
+
"no-op delete removed an unrelated key",
|
|
168
|
+
"no-op cross-org delete left our org's keys intact"
|
|
169
|
+
)
|
|
170
|
+
} finally {
|
|
171
|
+
// cleanup: remove any keys we created (creator-only default delete)
|
|
172
|
+
for (const id of adminKeyIds) {
|
|
173
|
+
await sdk.api.api_keys.deleteOne(id).catch(() => {})
|
|
174
|
+
}
|
|
175
|
+
// the admin can clean up the throwaway user's key org-wide; fall through is harmless
|
|
176
|
+
if (otherKeyId) {
|
|
177
|
+
await sdk.api.api_keys.delete_organization_api_key({ id: otherKeyId }).catch(() => {})
|
|
178
|
+
}
|
|
179
|
+
if (otherUserId) {
|
|
180
|
+
await sdk.api.users.deleteOne(otherUserId).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
if (apiKeysRoleId) {
|
|
183
|
+
await sdk.api.role_based_access_permissions.deleteOne(apiKeysRoleId).catch(() => {})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Allow running this test file independently
|
|
189
|
+
if (require.main === module) {
|
|
190
|
+
const sdk = new Session({ host })
|
|
191
|
+
const sdkNonAdmin = new Session({ host })
|
|
192
|
+
|
|
193
|
+
const runTests = async () => {
|
|
194
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
195
|
+
await organization_api_keys_tests({ sdk, sdkNonAdmin })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
runTests()
|
|
199
|
+
.then(() => {
|
|
200
|
+
console.log("✅ Organization API keys test suite completed successfully")
|
|
201
|
+
process.exit(0)
|
|
202
|
+
})
|
|
203
|
+
.catch((error) => {
|
|
204
|
+
console.error("❌ Organization API keys test suite failed:", error)
|
|
205
|
+
process.exit(1)
|
|
206
|
+
})
|
|
207
|
+
}
|