@tellescope/sdk 2.0.0-beta.0 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/cjs/sdk.d.ts +3 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +139 -9
  7. package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
  10. package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
  11. package/lib/cjs/tests/tests.d.ts.map +1 -1
  12. package/lib/cjs/tests/tests.js +231 -217
  13. package/lib/cjs/tests/tests.js.map +1 -1
  14. package/lib/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  15. package/lib/esm/sdk.d.ts +3 -0
  16. package/lib/esm/sdk.d.ts.map +1 -1
  17. package/lib/esm/sdk.js +1 -0
  18. package/lib/esm/sdk.js.map +1 -1
  19. package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
  20. package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +139 -9
  21. package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
  22. package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
  23. package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
  24. package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
  25. package/lib/esm/tests/tests.d.ts.map +1 -1
  26. package/lib/esm/tests/tests.js +231 -217
  27. package/lib/esm/tests/tests.js.map +1 -1
  28. package/lib/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +10 -10
  30. package/src/sdk.ts +6 -0
  31. package/src/tests/api_tests/enduser_fields_sync.test.ts +150 -0
  32. package/src/tests/api_tests/load_threads_care_team.test.ts +210 -0
  33. package/src/tests/api_tests/mdi_case_offerings.test.ts +138 -0
  34. package/src/tests/api_tests/security/add-to-journey-foreign-enduserid.test.ts +33 -26
  35. package/src/tests/api_tests/security/appointment-reschedule-ownership.test.ts +432 -0
  36. package/src/tests/api_tests/trigger_contains_matching.test.ts +345 -0
  37. package/src/tests/api_tests/user_logs_restore_deleted.test.ts +415 -0
  38. package/src/tests/tests.ts +12 -0
  39. package/src/tests/unit_tests/trigger_title_partials.test.ts +174 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/sdk",
3
- "version": "2.0.0-beta.0",
3
+ "version": "2.0.0-beta.1",
4
4
  "description": "Code for interacting with the Tellescope API",
5
5
  "main": "./lib/cjs/sdk.js",
6
6
  "module": "./lib/esm/sdk.js",
@@ -35,14 +35,14 @@
35
35
  "node": ">=22.12.0"
36
36
  },
37
37
  "dependencies": {
38
- "@tellescope/constants": "2.0.0-beta.0",
39
- "@tellescope/schema": "2.0.0-beta.0",
40
- "@tellescope/testing": "2.0.0-beta.0",
41
- "@tellescope/types-client": "2.0.0-beta.0",
42
- "@tellescope/types-models": "2.0.0-beta.0",
43
- "@tellescope/types-utilities": "2.0.0-beta.0",
44
- "@tellescope/utilities": "2.0.0-beta.0",
45
- "@tellescope/validation": "2.0.0-beta.0",
38
+ "@tellescope/constants": "2.0.0-beta.1",
39
+ "@tellescope/schema": "2.0.0-beta.1",
40
+ "@tellescope/testing": "2.0.0-beta.1",
41
+ "@tellescope/types-client": "2.0.0-beta.1",
42
+ "@tellescope/types-models": "2.0.0-beta.1",
43
+ "@tellescope/types-utilities": "2.0.0-beta.1",
44
+ "@tellescope/utilities": "2.0.0-beta.1",
45
+ "@tellescope/validation": "2.0.0-beta.1",
46
46
  "axios": "0.33.0",
47
47
  "dotenv": "14.3.2",
48
48
  "form-data": "4.0.6",
@@ -58,5 +58,5 @@
58
58
  "publishConfig": {
59
59
  "access": "public"
60
60
  },
61
- "gitHead": "95b2448bb219e7634e29ef4b7850f8f74d73dcbc"
61
+ "gitHead": "20732ce4e382a7d81dde795ce9c7194c2aca1ce6"
62
62
  }
package/src/sdk.ts CHANGED
@@ -234,6 +234,11 @@ const loadDefaultQueries = (s: Session): { [K in keyof ClientModelForName] : API
234
234
  })
235
235
 
236
236
  type Queries = { [K in keyof ClientModelForName]: APIQuery<K> } & {
237
+ user_logs: {
238
+ restore_deleted: (args: extractFields<CustomActions['user_logs']['restore_deleted']['parameters']>) => (
239
+ Promise<extractFields<CustomActions['user_logs']['restore_deleted']['returns']>>
240
+ ),
241
+ },
237
242
  inbox_threads: {
238
243
  build_threads: (args: extractFields<CustomActions['inbox_threads']['build_threads']['parameters']>) => (
239
244
  Promise<extractFields<CustomActions['inbox_threads']['build_threads']['returns']>>
@@ -996,6 +1001,7 @@ export class Session extends SessionManager {
996
1001
  queries.api_keys.get_organization_api_keys = () => this._GET(`/v1${schema.api_keys.customActions.get_organization_api_keys.path}`)
997
1002
  queries.api_keys.delete_organization_api_key = ({ id }) => this._DELETE(`/v1/organization-api-key/${id}`)
998
1003
 
1004
+ queries.user_logs.restore_deleted = a => this._POST(`/v1${schema.user_logs.customActions.restore_deleted.path}`, a)
999
1005
  queries.enduser_orders.get_available_tests = a => this._GET(`/v1${schema.enduser_orders.customActions.get_available_tests.path}`, a)
1000
1006
  queries.enduser_orders.create_lab_order = a => this._POST(`/v1${schema.enduser_orders.customActions.create_lab_order.path}`, a)
1001
1007
  queries.enduser_orders.cancel_order = a => this._POST(`/v1${schema.enduser_orders.customActions.cancel_order.path}`, a)
@@ -0,0 +1,150 @@
1
+ require('source-map-support').install();
2
+
3
+ import {
4
+ assert_checks,
5
+ Check,
6
+ setup_isolated_tenant,
7
+ } from "../setup_isolated"
8
+
9
+ const host = process.env.API_URL || 'http://localhost:8080' as const
10
+
11
+ const RAND = () => Math.random().toString(36).slice(2, 10)
12
+
13
+ /**
14
+ * Schema round-trip for `Form.syncEnduserFieldsToEHR` and `Form.enduserFieldsSync`, the two fields
15
+ * behind "Sync Patient Fields to Healthie".
16
+ *
17
+ * The names are deliberately EHR-agnostic — each row's `externalId` is the question id in whichever
18
+ * EHR the form syncs to (`Form.externalId`) — even though only the Healthie push reads them today.
19
+ *
20
+ * What this file can and cannot cover: the actual `createFormAnswerGroup` round trip needs live
21
+ * Healthie credentials that no suite here has, and there is no Healthie mock or fixture layer in the
22
+ * repository, so the push itself is covered by the resolver's unit tests
23
+ * (api/modules/forms.test.ts) plus manual QA against Healthie staging. What IS covered here is
24
+ * everything the API owns: that the fields persist and read back unchanged, that validation rejects
25
+ * malformed rows, and — the one that guards a real decision in the form-editor modal — that an
26
+ * update with `replaceObjectFields` actually SHRINKS the list rather than merging into it, which is
27
+ * what makes deleting a mapping row persist.
28
+ */
29
+ export const enduser_fields_sync_checks = async (): Promise<Check[]> => {
30
+ const checks: Check[] = []
31
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
32
+
33
+ /** records a check that the given write is rejected (any 4xx), not silently accepted */
34
+ const record_rejected = async (label: string, write: () => Promise<any>) => {
35
+ try {
36
+ const accepted = await write()
37
+ record(label, false, `accepted, expected a validation error — got ${JSON.stringify(accepted?.enduserFieldsSync)}`)
38
+ } catch (err: any) {
39
+ const message = err?.message || String(err)
40
+ record(label, true, `rejected: ${message.slice(0, 200)}`)
41
+ }
42
+ }
43
+
44
+ let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
45
+
46
+ try {
47
+ tenant = await setup_isolated_tenant({ name: `FieldSync ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 })
48
+ const sdk = tenant.sdk
49
+
50
+ const TWO_ROWS = [
51
+ { field: 'dateOfBirth', externalId: '111111' },
52
+ { field: 'Referring Provider', externalId: '222222' },
53
+ ]
54
+
55
+ /* ===== 1. Create with both fields set, and read them back ===== */
56
+ const created = await sdk.api.forms.createOne({
57
+ title: `Field sync ${RAND()}`,
58
+ externalId: 'healthie-form-id',
59
+ syncEnduserFieldsToEHR: true,
60
+ enduserFieldsSync: TWO_ROWS,
61
+ })
62
+ const readBack = await sdk.api.forms.getOne(created.id)
63
+
64
+ record('1: create persists syncEnduserFieldsToEHR', readBack.syncEnduserFieldsToEHR === true,
65
+ `got ${JSON.stringify(readBack.syncEnduserFieldsToEHR)}`)
66
+ record('1: create persists enduserFieldsSync verbatim',
67
+ JSON.stringify(readBack.enduserFieldsSync) === JSON.stringify(TWO_ROWS),
68
+ `got ${JSON.stringify(readBack.enduserFieldsSync)} expected ${JSON.stringify(TWO_ROWS)}`)
69
+
70
+ /* ===== 2. replaceObjectFields must SHRINK the list, not merge into it =====
71
+ This is the guard for the form-editor modal's `replaceObjectFields` change. Without it,
72
+ deleting a mapping row in the UI would save and then silently come back on reload. */
73
+ const ONE_ROW = [{ field: 'insurance.memberId', externalId: '333333' }]
74
+ await sdk.api.forms.updateOne(created.id, { enduserFieldsSync: ONE_ROW }, { replaceObjectFields: true })
75
+ const shrunk = await sdk.api.forms.getOne(created.id)
76
+
77
+ record('2: replaceObjectFields shrinks a two-row list to one row',
78
+ JSON.stringify(shrunk.enduserFieldsSync) === JSON.stringify(ONE_ROW),
79
+ `got ${JSON.stringify(shrunk.enduserFieldsSync)} expected ${JSON.stringify(ONE_ROW)}`)
80
+
81
+ /* ===== 3. Malformed rows are rejected ===== */
82
+ await record_rejected('3: a row with a non-string field is rejected', () =>
83
+ sdk.api.forms.updateOne(created.id, { enduserFieldsSync: [{ field: 1, externalId: '1' }] } as any,
84
+ { replaceObjectFields: true }))
85
+
86
+ await record_rejected('3: a row missing externalId is rejected', () =>
87
+ sdk.api.forms.updateOne(created.id, { enduserFieldsSync: [{ field: 'fname' }] } as any,
88
+ { replaceObjectFields: true }))
89
+
90
+ await record_rejected('3: an externalId over 100 characters is rejected', () =>
91
+ sdk.api.forms.updateOne(created.id,
92
+ { enduserFieldsSync: [{ field: 'fname', externalId: 'x'.repeat(101) }] },
93
+ { replaceObjectFields: true }))
94
+
95
+ // a rejected write must not have partially applied - the one-row list from step 2 stands
96
+ const afterRejections = await sdk.api.forms.getOne(created.id)
97
+ record('3: rejected writes leave the stored list untouched',
98
+ JSON.stringify(afterRejections.enduserFieldsSync) === JSON.stringify(ONE_ROW),
99
+ `got ${JSON.stringify(afterRejections.enduserFieldsSync)} expected ${JSON.stringify(ONE_ROW)}`)
100
+
101
+ /* ===== 4. An empty list is accepted (clearing every row) ===== */
102
+ try {
103
+ await sdk.api.forms.updateOne(created.id, { enduserFieldsSync: [] }, { replaceObjectFields: true })
104
+ const cleared = await sdk.api.forms.getOne(created.id)
105
+ const rows = cleared.enduserFieldsSync
106
+ record('4: an empty list is accepted and clears the mapping',
107
+ Array.isArray(rows) && rows.length === 0,
108
+ `got ${JSON.stringify(rows)}`)
109
+ } catch (err: any) {
110
+ record('4: an empty list is accepted and clears the mapping', false,
111
+ `rejected: ${(err?.message || String(err)).slice(0, 200)}`)
112
+ }
113
+
114
+ /* ===== 5. Backward compatibility: a form that sets neither field reads back with neither ===== */
115
+ const plain = await sdk.api.forms.createOne({ title: `No field sync ${RAND()}` })
116
+ const plainReadBack = await sdk.api.forms.getOne(plain.id)
117
+
118
+ record('5: a form created without either field has no syncEnduserFieldsToEHR',
119
+ plainReadBack.syncEnduserFieldsToEHR === undefined,
120
+ `got ${JSON.stringify(plainReadBack.syncEnduserFieldsToEHR)}`)
121
+ record('5: a form created without either field has no enduserFieldsSync',
122
+ plainReadBack.enduserFieldsSync === undefined,
123
+ `got ${JSON.stringify(plainReadBack.enduserFieldsSync)}`)
124
+ } finally {
125
+ // Teardown BEFORE anything asserts — assert calls process.exit, which skips pending finallys and
126
+ // would strand the provisioned tenant permanently.
127
+ if (tenant) { try { await tenant.teardown() } catch (e) { console.error('teardown failed', e) } }
128
+ }
129
+
130
+ return checks
131
+ }
132
+
133
+ /** Asserting wrapper, for standalone runs and for sequential use in the suite. */
134
+ export const enduser_fields_sync_tests = async () => {
135
+ assert_checks('Enduser fields sync schema round-trip', await enduser_fields_sync_checks())
136
+ }
137
+
138
+ // Allow running this test file independently
139
+ if (require.main === module) {
140
+ console.log(`🌐 Using API URL: ${host}`)
141
+ enduser_fields_sync_tests()
142
+ .then(() => {
143
+ console.log("✅ enduser fields sync test suite completed successfully")
144
+ process.exit(0)
145
+ })
146
+ .catch((error) => {
147
+ console.error("❌ enduser fields sync test suite failed:", error)
148
+ process.exit(1)
149
+ })
150
+ }
@@ -0,0 +1,210 @@
1
+ require('source-map-support').install();
2
+
3
+ import {
4
+ assert_checks,
5
+ Check,
6
+ setup_isolated_tenant,
7
+ } from "../setup_isolated"
8
+
9
+ const host = process.env.API_URL || 'http://localhost:8080' as const
10
+
11
+ const RAND = () => Math.random().toString(36).slice(2, 10)
12
+
13
+ /**
14
+ * Coverage for the `careTeamUserIds` parameter on `load_threads` (GET /v1/inbox-threads/load).
15
+ *
16
+ * The caller used to resolve "every patient these users are on the care team for" client-side and
17
+ * push the resulting enduser ids into `mdbFilter`. For a care team of hundreds of patients that
18
+ * overflows the GET query string (Node's 16KB header limit) before any Tellescope code runs. The
19
+ * parameter moves the join to the server: the API resolves the user ids to endusers via
20
+ * `assignedTo`, and ANDs `{ enduserIds: { $in: [...] } }` onto the thread filter.
21
+ *
22
+ * `setup_isolated` rather than `setup_tests` because several checks assert on the COMPLETE thread
23
+ * set of the tenant. On the shared fixture org, `build_threads` and the other inbox suites leave
24
+ * threads behind and every "returns exactly these" assertion would be flaky.
25
+ *
26
+ * The threads are created directly with `inbox_threads.createOne` (the model exposes create for
27
+ * exactly this reason) rather than via `build_threads`, so the tenant contains exactly three.
28
+ *
29
+ * Check 4 is the one that catches a wrong implementation: a care team with no patients resolves to
30
+ * an empty enduser list, and the clause must still be applied (returning nothing) rather than
31
+ * dropped (returning everything). Check 5 is its counterweight — it rules out an implementation
32
+ * that returns nothing for every call, which would satisfy 3, 4 and 7c vacuously.
33
+ *
34
+ * Check 7 is the permission boundary. A provisioned non-admin gets
35
+ * `sms_messages: ASSIGNED_AND_DEFAULT_ACCESS` (read: 'Assigned'), and the fixture threads carry
36
+ * `userIds: []`, so that session sees a thread only through the assigned-enduser branch. 7c fails
37
+ * if the care-team clause is ever pushed into the access `$or` instead of `filter.$and`, i.e. if
38
+ * the parameter could be used to WIDEN what a session can see.
39
+ */
40
+ export const load_threads_care_team_checks = async (): Promise<Check[]> => {
41
+ const checks: Check[] = []
42
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
43
+
44
+ let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
45
+
46
+ try {
47
+ /* ================= fixtures ================= */
48
+
49
+ tenant = await setup_isolated_tenant({
50
+ name: `LoadThreadsCareTeam ${RAND()}`,
51
+ users: [{ isAdmin: true }, { roles: ['Non-Admin'] }],
52
+ apiKeys: 0,
53
+ })
54
+
55
+ const { sdk, sdkNonAdmin } = tenant
56
+ const NA = tenant.users[1].id // the non-admin's own user id
57
+
58
+ // care-team users: they never log in, they exist only to appear in endusers.assignedTo
59
+ const [U1, U2, U3] = await Promise.all([1, 2, 3].map(n => sdk.api.users.createOne({
60
+ email: `dontsend-care-team-${n}-${RAND()}@tellescope.com`,
61
+ fname: 'CareTeam', lname: `User${n}`,
62
+ verifiedEmail: true,
63
+ notificationEmailsDisabled: true,
64
+ })))
65
+
66
+ // E1 is also assigned to the non-admin, so check 7a has a non-empty baseline to measure against
67
+ const E1 = await sdk.api.endusers.createOne({ fname: 'CareTeam', lname: 'PatientOne', assignedTo: [U1.id, NA] })
68
+ const E2 = await sdk.api.endusers.createOne({ fname: 'CareTeam', lname: 'PatientTwo', assignedTo: [U2.id] })
69
+ const E3 = await sdk.api.endusers.createOne({ fname: 'CareTeam', lname: 'PatientThree', assignedTo: [] })
70
+
71
+ const now = Date.now()
72
+ const MINUTE = 60 * 1000
73
+ const defaultThreadFields = {
74
+ type: 'SMS' as const,
75
+ assignedTo: [],
76
+ userIds: [],
77
+ inboxStatus: 'New',
78
+ preview: 'care team test',
79
+ title: 'Care team test',
80
+ }
81
+
82
+ const T1 = await sdk.api.inbox_threads.createOne({
83
+ ...defaultThreadFields,
84
+ enduserIds: [E1.id], threadId: `care-team-t1-${RAND()}`, timestamp: new Date(now - 3 * MINUTE),
85
+ })
86
+ const T2 = await sdk.api.inbox_threads.createOne({
87
+ ...defaultThreadFields,
88
+ enduserIds: [E2.id], threadId: `care-team-t2-${RAND()}`, timestamp: new Date(now - 2 * MINUTE),
89
+ })
90
+ const T3 = await sdk.api.inbox_threads.createOne({
91
+ ...defaultThreadFields,
92
+ enduserIds: [E3.id], threadId: `care-team-t3-${RAND()}`, timestamp: new Date(now - 1 * MINUTE),
93
+ })
94
+
95
+ const label_for: { [id: string]: string } = { [T1.id]: 'T1', [T2.id]: 'T2', [T3.id]: 'T3' }
96
+ const describe = (ids: string[]) => `[${ids.map(id => label_for[id] || id).sort().join(', ')}]`
97
+ const load = async (args: any, session = sdk) => (
98
+ (await session.api.inbox_threads.load_threads(args)).threads.map(t => t.id)
99
+ )
100
+ const same_set = (a: string[], b: string[]) => (
101
+ a.length === b.length && [...a].sort().join() === [...b].sort().join()
102
+ )
103
+
104
+ /* ===== 1: a single care-team user ===== */
105
+
106
+ const one = await load({ careTeamUserIds: [U1.id] })
107
+ record(
108
+ '1: careTeamUserIds=[U1] returns exactly the thread for U1\'s patient',
109
+ same_set(one, [T1.id]),
110
+ `got ${describe(one)} (want [T1])`,
111
+ )
112
+
113
+ /* ===== 2: multiple care-team users union their patients ===== */
114
+
115
+ const two = await load({ careTeamUserIds: [U1.id, U2.id] })
116
+ record(
117
+ '2: careTeamUserIds=[U1, U2] returns both their threads and not the unassigned one',
118
+ same_set(two, [T1.id, T2.id]),
119
+ `got ${describe(two)} (want [T1, T2] — T3\'s enduser has an empty care team)`,
120
+ )
121
+
122
+ /* ===== 3: ANDs with enduserIds rather than ORing ===== */
123
+
124
+ const intersect = await load({ careTeamUserIds: [U2.id], enduserIds: [E1.id] })
125
+ record(
126
+ '3: careTeamUserIds AND enduserIds narrows to the intersection',
127
+ intersect.length === 0,
128
+ `got ${describe(intersect)} (want [] — E1 is not on U2\'s care team; a non-empty result means the`
129
+ + ` two filters OR instead of AND)`,
130
+ )
131
+
132
+ /* ===== 4: an empty resolution must not degrade to "no filter" ===== */
133
+
134
+ const emptyCareTeam = await load({ careTeamUserIds: [U3.id] })
135
+ record(
136
+ '4: a care-team user with no patients returns no threads, not every thread',
137
+ emptyCareTeam.length === 0,
138
+ `got ${describe(emptyCareTeam)} (want [] — returning all three means the clause was dropped when`
139
+ + ` the enduser lookup came back empty)`,
140
+ )
141
+
142
+ /* ===== 5: the feature-off path is unchanged ===== */
143
+
144
+ const baseline = await load({})
145
+ const emptyParam = await load({ careTeamUserIds: [] })
146
+ record(
147
+ '5: omitting careTeamUserIds, and passing [], both return exactly the three fixture threads',
148
+ same_set(baseline, [T1.id, T2.id, T3.id]) && same_set(emptyParam, baseline),
149
+ `omitted=${describe(baseline)} empty=${describe(emptyParam)} (want [T1, T2, T3] for both)`,
150
+ )
151
+
152
+ /* ===== 6: the count path shares the filter ===== */
153
+
154
+ const { count } = await sdk.api.inbox_threads.load_threads({ careTeamUserIds: [U1.id], returnCount: true })
155
+ record(
156
+ '6: returnCount applies the care-team filter too',
157
+ count === 1,
158
+ `count=${count} (want 1 — 3 means the count path skipped the filter)`,
159
+ )
160
+
161
+ /* ===== 7: permission boundary — the parameter can only narrow, never widen ===== */
162
+
163
+ const naBaseline = await load({}, sdkNonAdmin)
164
+ record(
165
+ '7a: the non-admin\'s unfiltered inbox is exactly the thread for the patient assigned to them',
166
+ same_set(naBaseline, [T1.id]),
167
+ `got ${describe(naBaseline)} (want [T1] — the baseline the next two checks are measured against;`
168
+ + ` if this is empty, 7b and 7c pass vacuously)`,
169
+ )
170
+
171
+ const naOwnCareTeam = await load({ careTeamUserIds: [U1.id] }, sdkNonAdmin)
172
+ record(
173
+ '7b: the parameter does not remove a thread the non-admin legitimately sees',
174
+ same_set(naOwnCareTeam, [T1.id]),
175
+ `got ${describe(naOwnCareTeam)} (want [T1])`,
176
+ )
177
+
178
+ const naForeignCareTeam = await load({ careTeamUserIds: [U2.id] }, sdkNonAdmin)
179
+ record(
180
+ '7c: the parameter cannot reach a thread outside the non-admin\'s access',
181
+ naForeignCareTeam.length === 0,
182
+ `got ${describe(naForeignCareTeam)} (want [] — T2 matches U2\'s care team but is outside this`
183
+ + ` session's access; a non-empty result means the clause was pushed into the access $or)`,
184
+ )
185
+ } finally {
186
+ // Teardown BEFORE anything asserts — assert calls process.exit, which would strand the tenant.
187
+ if (tenant) { try { await tenant.teardown() } catch (e) { console.error('teardown failed', e) } }
188
+ }
189
+
190
+ return checks
191
+ }
192
+
193
+ /** Asserting wrapper, for standalone runs and for sequential use in the suite. */
194
+ export const load_threads_care_team_tests = async () => {
195
+ assert_checks('Load threads care team', await load_threads_care_team_checks())
196
+ }
197
+
198
+ // Allow running this test file independently
199
+ if (require.main === module) {
200
+ console.log(`🌐 Using API URL: ${host}`)
201
+ load_threads_care_team_tests()
202
+ .then(() => {
203
+ console.log("✅ Load threads care team test suite completed successfully")
204
+ process.exit(0)
205
+ })
206
+ .catch((error) => {
207
+ console.error("❌ Load threads care team test suite failed:", error)
208
+ process.exit(1)
209
+ })
210
+ }
@@ -91,10 +91,41 @@ const resolve_displayed_options = (
91
91
  ) ? field.options.choices : undefined
92
92
  }
93
93
 
94
+ // Local replica of the API's mdiIgnore handling (md_integrations.ts): the same field lookup
95
+ // (_id first, externalId fallback — Formsort / form-ingestion answers carry no fieldId), with
96
+ // patient-mapped fields exempt. Used to filter both case_questions and case file uploads.
97
+ type IgnorableField = { _id: string, externalId?: string, mdiIgnore?: boolean }
98
+ type IgnorableResponse = { fieldId?: string, externalId?: string, answer: { type?: string } }
99
+
100
+ const is_mdi_ignored = (response: IgnorableResponse, formFields: IgnorableField[]): boolean => {
101
+ if (response.externalId && PATIENT_MAPPED_EXTERNAL_IDS.has(response.externalId)) return false
102
+ const field = (
103
+ formFields.find(f => f._id.toString() === response.fieldId)
104
+ || formFields.find(f => response.externalId && f.externalId === response.externalId)
105
+ )
106
+ return !!field?.mdiIgnore
107
+ }
108
+
109
+ const case_question_responses = (responses: IgnorableResponse[], formFields: IgnorableField[]) => (
110
+ responses.filter(r =>
111
+ r.answer.type !== 'file' && r.answer.type !== 'files'
112
+ && !(r.externalId && PATIENT_MAPPED_EXTERNAL_IDS.has(r.externalId))
113
+ && !is_mdi_ignored(r, formFields)
114
+ )
115
+ )
116
+
117
+ const uploadable_file_responses = (responses: IgnorableResponse[], formFields: IgnorableField[]) => (
118
+ responses.filter(r =>
119
+ (r.answer.type === 'file' || r.answer.type === 'files')
120
+ && !is_mdi_ignored(r, formFields)
121
+ )
122
+ )
123
+
94
124
  export const mdi_case_offerings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
95
125
  log_header("MDI Case Offerings Tests")
96
126
 
97
127
  let testFormId: string | undefined
128
+ let testFieldId: string | undefined
98
129
 
99
130
  try {
100
131
  // --- Schema round-trip tests ---
@@ -385,7 +416,114 @@ export const mdi_case_offerings_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sess
385
416
  },
386
417
  { expectedResult: true }
387
418
  )
419
+
420
+ // --- mdiIgnore tests ---
421
+
422
+ await async_test(
423
+ "Form field mdiIgnore round-trips through create and update",
424
+ async () => {
425
+ if (!testFormId) throw new Error("No test form")
426
+
427
+ const field = await sdk.api.form_fields.createOne({
428
+ formId: testFormId,
429
+ title: 'Offering IDs',
430
+ type: 'string',
431
+ externalId: 'offeringIds',
432
+ mdiIgnore: true,
433
+ previousFields: [{ type: 'root', info: {} }],
434
+ })
435
+ testFieldId = field.id
436
+ const created = await sdk.api.form_fields.getOne(field.id)
437
+
438
+ await sdk.api.form_fields.updateOne(field.id, { mdiIgnore: false })
439
+ const updated = await sdk.api.form_fields.getOne(field.id)
440
+
441
+ return created.mdiIgnore === true && updated.mdiIgnore === false
442
+ },
443
+ { expectedResult: true }
444
+ )
445
+
446
+ const IGNORE_FIELDS: IgnorableField[] = [
447
+ { _id: 'field-ignored', externalId: 'internal_note', mdiIgnore: true },
448
+ { _id: 'field-offerings', externalId: 'offeringIds', mdiIgnore: true },
449
+ { _id: 'field-kept', externalId: 'symptoms' },
450
+ { _id: 'field-kept-explicit', externalId: 'duration', mdiIgnore: false },
451
+ { _id: 'field-allergies', externalId: 'allergies', mdiIgnore: true }, // patient-mapped → exempt
452
+ { _id: 'field-ignored-upload', externalId: 'internal_upload', mdiIgnore: true },
453
+ { _id: 'field-kept-upload', externalId: 'mdi:lab-result' },
454
+ ]
455
+
456
+ await async_test(
457
+ "mdiIgnore drops native form answers (matched by fieldId) from case_questions",
458
+ async () => {
459
+ const kept = case_question_responses([
460
+ { fieldId: 'field-ignored', answer: { type: 'string' } },
461
+ { fieldId: 'field-kept', answer: { type: 'string' } },
462
+ { fieldId: 'field-kept-explicit', answer: { type: 'string' } },
463
+ ], IGNORE_FIELDS)
464
+ return kept.length === 2 && kept.every(r => r.fieldId !== 'field-ignored')
465
+ },
466
+ { expectedResult: true }
467
+ )
468
+
469
+ await async_test(
470
+ "mdiIgnore drops Formsort / form-ingestion answers (matched by externalId) from case_questions",
471
+ async () => {
472
+ const kept = case_question_responses([
473
+ { fieldId: '', externalId: 'offeringIds', answer: { type: 'string' } },
474
+ { fieldId: '', externalId: 'symptoms', answer: { type: 'string' } },
475
+ { fieldId: '', externalId: 'unconfigured_key', answer: { type: 'string' } }, // no field → kept
476
+ ], IGNORE_FIELDS)
477
+ return (
478
+ kept.length === 2
479
+ && !kept.some(r => r.externalId === 'offeringIds')
480
+ && kept.some(r => r.externalId === 'unconfigured_key')
481
+ )
482
+ },
483
+ { expectedResult: true }
484
+ )
485
+
486
+ await async_test(
487
+ "mdiIgnore does not affect patient-mapped fields",
488
+ async () => (
489
+ !is_mdi_ignored({ fieldId: 'field-allergies', externalId: 'allergies', answer: { type: 'string' } }, IGNORE_FIELDS)
490
+ ),
491
+ { expectedResult: true }
492
+ )
493
+
494
+ await async_test(
495
+ "mdiIgnore skips file uploads for ignored fields only",
496
+ async () => {
497
+ const uploadable = uploadable_file_responses([
498
+ { fieldId: 'field-ignored-upload', answer: { type: 'file' } },
499
+ { fieldId: '', externalId: 'internal_upload', answer: { type: 'files' } },
500
+ { fieldId: 'field-kept-upload', answer: { type: 'file' } },
501
+ ], IGNORE_FIELDS)
502
+ return uploadable.length === 1 && uploadable[0].fieldId === 'field-kept-upload'
503
+ },
504
+ { expectedResult: true }
505
+ )
506
+
507
+ await async_test(
508
+ "mdiIgnore does not stop offering conditions from reading the answer",
509
+ async () => {
510
+ const offerings: MDIOffering[] = [
511
+ { offering_id: "03b7", conditions: { condition: { offeringIds: { $contains: "03b7" } as any } } },
512
+ ]
513
+ const responses = [{ externalId: 'offeringIds', value: '1d15,03b7,03b7' }]
514
+ const resolved = resolve_case_offerings(offerings, responses)
515
+ const questions = case_question_responses(
516
+ [{ fieldId: '', externalId: 'offeringIds', answer: { type: 'string' } }],
517
+ IGNORE_FIELDS,
518
+ )
519
+ return resolved.length === 1 && questions.length === 0
520
+ },
521
+ { expectedResult: true }
522
+ )
388
523
  } finally {
524
+ if (testFieldId) {
525
+ await sdk.api.form_fields.deleteOne(testFieldId).catch(console.error)
526
+ }
389
527
  if (testFormId) {
390
528
  await sdk.api.forms.deleteOne(testFormId).catch(console.error)
391
529
  }
@@ -5,6 +5,7 @@ import {
5
5
  Check,
6
6
  setup_isolated_tenant,
7
7
  } from "../../setup_isolated"
8
+ import { poll_for_results } from "@tellescope/testing"
8
9
 
9
10
  const host = process.env.API_URL || 'http://localhost:8080' as const
10
11
 
@@ -97,6 +98,11 @@ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]
97
98
  await attacker!.sdk.api.automated_actions.getSome({ filter: { journeyId } } as any)
98
99
  )
99
100
  const enduserIdsFor = async (journeyId: string) => (await rowsFor(journeyId)).map((r: any) => r.enduserId)
101
+ // add_to_journey's automated_actions.insertMany is fire-and-forget on both branches, so the 204
102
+ // can arrive before the rows exist: poll until the expected id shows (last read on timeout)
103
+ const enduserIdsForOnceContains = (journeyId: string, enduserId: string) => (
104
+ poll_for_results(() => enduserIdsFor(journeyId), ids => ids.includes(enduserId))
105
+ )
100
106
 
101
107
  /* ===== A: the foreign id must never be planted, on EITHER branch ===== */
102
108
 
@@ -108,13 +114,6 @@ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]
108
114
  startAt: FUTURE_START(),
109
115
  } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
110
116
 
111
- const inlineIds = await enduserIdsFor(inline.journey.id)
112
- record(
113
- 'A1: inline branch (automationStepId) does not plant a foreign enduserId',
114
- !inlineIds.includes(victim.id),
115
- `call=${inlineResult}; automated_actions enduserIds=${JSON.stringify(inlineIds)} (must not contain ${victim.id})`,
116
- )
117
-
118
117
  // A2 — module branch: no automationStepId, so it routes through handle_automation_event
119
118
  const moduleResult = await attacker.sdk.api.endusers.add_to_journey({
120
119
  enduserIds: [victim.id],
@@ -122,22 +121,6 @@ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]
122
121
  startAt: FUTURE_START(),
123
122
  } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
124
123
 
125
- const moduleIds = await enduserIdsFor(modulePath.journey.id)
126
- record(
127
- 'A2: module branch (no automationStepId) does not plant a foreign enduserId',
128
- !moduleIds.includes(victim.id),
129
- `call=${moduleResult}; automated_actions enduserIds=${JSON.stringify(moduleIds)} (must not contain ${victim.id})`,
130
- )
131
-
132
- // A3 — the victim's own record must be untouched (the scoped updateMany should already ensure it)
133
- const victimAfter = await victimTenant.sdk.api.endusers.getOne(victim.id)
134
- const victimJourneys = (victimAfter as any)?.journeys ?? {}
135
- record(
136
- 'A3: the victim enduser was not enrolled in the attacker\'s journey',
137
- !victimJourneys[inline.journey.id] && !victimJourneys[modulePath.journey.id],
138
- `victim journeys=${JSON.stringify(victimJourneys)}`,
139
- )
140
-
141
124
  /* ===== B: legitimate use must keep working ===== */
142
125
 
143
126
  // B1 — positive control on both branches, so a blanket failure can't masquerade as a fix
@@ -153,8 +136,32 @@ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]
153
136
  startAt: FUTURE_START(),
154
137
  } as any)
155
138
 
156
- const inlineOwn = await enduserIdsFor(inline.journey.id)
157
- const moduleOwn = await enduserIdsFor(modulePath.journey.id)
139
+ const inlineOwn = await enduserIdsForOnceContains(inline.journey.id, own.id)
140
+ const moduleOwn = await enduserIdsForOnceContains(modulePath.journey.id, own.id)
141
+
142
+ // A1/A2 are judged against the post-B1 reads: those include every row for the journey, and the
143
+ // legitimate insert having landed is what makes the victim's absence meaningful rather than
144
+ // "not written yet"
145
+ record(
146
+ 'A1: inline branch (automationStepId) does not plant a foreign enduserId',
147
+ !inlineOwn.includes(victim.id),
148
+ `call=${inlineResult}; automated_actions enduserIds=${JSON.stringify(inlineOwn)} (must not contain ${victim.id})`,
149
+ )
150
+ record(
151
+ 'A2: module branch (no automationStepId) does not plant a foreign enduserId',
152
+ !moduleOwn.includes(victim.id),
153
+ `call=${moduleResult}; automated_actions enduserIds=${JSON.stringify(moduleOwn)} (must not contain ${victim.id})`,
154
+ )
155
+
156
+ // A3 — the victim's own record must be untouched (the scoped updateMany should already ensure it)
157
+ const victimAfter = await victimTenant.sdk.api.endusers.getOne(victim.id)
158
+ const victimJourneys = (victimAfter as any)?.journeys ?? {}
159
+ record(
160
+ 'A3: the victim enduser was not enrolled in the attacker\'s journey',
161
+ !victimJourneys[inline.journey.id] && !victimJourneys[modulePath.journey.id],
162
+ `victim journeys=${JSON.stringify(victimJourneys)}`,
163
+ )
164
+
158
165
  record(
159
166
  'B1: the caller\'s OWN enduser still enrolls on both branches',
160
167
  inlineOwn.includes(own.id) && moduleOwn.includes(own.id),
@@ -170,7 +177,7 @@ export const add_to_journey_foreign_enduserid_checks = async (): Promise<Check[]
170
177
  startAt: FUTURE_START(),
171
178
  } as any).then(() => 'resolved').catch((e: any) => `rejected: ${e?.message || e}`)
172
179
 
173
- const mixedIds = await enduserIdsFor(mixed.journey.id)
180
+ const mixedIds = await enduserIdsForOnceContains(mixed.journey.id, own.id)
174
181
  record(
175
182
  'B2: a mixed list enrolls the visible enduser and drops only the foreign one',
176
183
  mixedIds.includes(own.id) && !mixedIds.includes(victim.id),