@tellescope/sdk 1.256.10 → 1.256.12
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 +2 -2
- package/lib/cjs/tests/api_tests/active_patient_count.test.d.ts +15 -0
- package/lib/cjs/tests/api_tests/active_patient_count.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/active_patient_count.test.js +594 -0
- package/lib/cjs/tests/api_tests/active_patient_count.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/generate_suggested_reply.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.js +192 -0
- package/lib/cjs/tests/api_tests/out_of_office_endpoint.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +5 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +95 -31
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.d.ts +26 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.js +336 -0
- package/lib/cjs/tests/api_tests/security/suggested-reply-redaction.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.js +205 -0
- package/lib/cjs/tests/api_tests/stedi_approval.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +240 -203
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +4 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/active_patient_count.test.d.ts +15 -0
- package/lib/esm/tests/api_tests/active_patient_count.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/active_patient_count.test.js +590 -0
- package/lib/esm/tests/api_tests/active_patient_count.test.js.map +1 -0
- package/lib/esm/tests/api_tests/generate_suggested_reply.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/out_of_office_endpoint.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/out_of_office_endpoint.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/out_of_office_endpoint.test.js +188 -0
- package/lib/esm/tests/api_tests/out_of_office_endpoint.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +5 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +95 -31
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.d.ts +26 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.js +332 -0
- package/lib/esm/tests/api_tests/security/suggested-reply-redaction.test.js.map +1 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.js +201 -0
- package/lib/esm/tests/api_tests/stedi_approval.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +240 -203
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/active_patient_count.test.ts +435 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +56 -0
- package/src/tests/api_tests/security/readonly-enduser-preview.test.ts +301 -0
- package/src/tests/api_tests/security/suggested-reply-redaction.test.ts +232 -0
- package/src/tests/api_tests/stedi_approval.test.ts +167 -0
- package/src/tests/tests.ts +36 -13
- package/test_generated.pdf +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tellescope/sdk",
|
|
3
|
-
"version": "1.256.
|
|
3
|
+
"version": "1.256.12",
|
|
4
4
|
"description": "Code for interacting with the Tellescope API",
|
|
5
5
|
"main": "./lib/cjs/sdk.js",
|
|
6
6
|
"module": "./lib/esm/sdk.js",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
},
|
|
33
33
|
"homepage": "https://github.com/tellescope-os/tellescope#readme",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@tellescope/constants": "1.256.
|
|
36
|
-
"@tellescope/schema": "1.256.
|
|
37
|
-
"@tellescope/testing": "1.256.
|
|
38
|
-
"@tellescope/types-client": "1.256.
|
|
39
|
-
"@tellescope/types-models": "1.256.
|
|
40
|
-
"@tellescope/types-utilities": "1.256.
|
|
41
|
-
"@tellescope/utilities": "1.256.
|
|
42
|
-
"@tellescope/validation": "1.256.
|
|
35
|
+
"@tellescope/constants": "1.256.12",
|
|
36
|
+
"@tellescope/schema": "1.256.12",
|
|
37
|
+
"@tellescope/testing": "1.256.12",
|
|
38
|
+
"@tellescope/types-client": "1.256.12",
|
|
39
|
+
"@tellescope/types-models": "1.256.12",
|
|
40
|
+
"@tellescope/types-utilities": "1.256.12",
|
|
41
|
+
"@tellescope/utilities": "1.256.12",
|
|
42
|
+
"@tellescope/validation": "1.256.12",
|
|
43
43
|
"axios": "0.30.3",
|
|
44
44
|
"dotenv": "14.3.2",
|
|
45
45
|
"form-data": "4.0.4",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"publishConfig": {
|
|
56
56
|
"access": "public"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "137bfca8fc8d2229a8eee245265536e507a3afae"
|
|
59
59
|
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
async_test,
|
|
6
|
+
handleAnyError,
|
|
7
|
+
log_header,
|
|
8
|
+
wait,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { authenticate_enduser_via_token, setup_tests } from "../setup"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
const businessId = '60398b1131a295e64f084ff6'
|
|
14
|
+
|
|
15
|
+
// Second-tenant session, used to prove the active patient count never crosses tenants.
|
|
16
|
+
// Same key used by the org-isolation tests in tests.ts.
|
|
17
|
+
const OTHER_TENANT_API_KEY = "ba745e25162bb95a795c5fa1af70df188d93c4d3aac9c48b34a5c8c9dd7b80f7"
|
|
18
|
+
|
|
19
|
+
// Arbitrary valid ObjectId — endusers carrying a customTypeId are excluded from the default count
|
|
20
|
+
const TEST_CUSTOM_TYPE_ID = '60398b0231a295e64f084fd9'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Locks in the behavior of getEngagedEndusers (endusers/engagement), which backs both the billing
|
|
24
|
+
* /usage endpoint and the super-admin active patient count. Every case creates exactly one
|
|
25
|
+
* qualifying (or deliberately non-qualifying) engagement record and asserts its effect on the
|
|
26
|
+
* distinct enduser count.
|
|
27
|
+
*
|
|
28
|
+
* All assertions are made inside a time window opened at the top of the test, so unrelated records
|
|
29
|
+
* left behind by other suites can't move the numbers.
|
|
30
|
+
*/
|
|
31
|
+
export const active_patient_count_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
32
|
+
log_header("Active Patient Count Tests")
|
|
33
|
+
|
|
34
|
+
const userId = sdk.userInfo.id
|
|
35
|
+
|
|
36
|
+
// _id-based range filters use ObjectId.createFromTime, which floors to whole seconds — back off a
|
|
37
|
+
// couple of seconds so nothing created below can land before the window starts.
|
|
38
|
+
const windowStart = new Date(Date.now() - 2000)
|
|
39
|
+
|
|
40
|
+
const activeCount = (args: Parameters<typeof sdk.api.endusers.get_engagement_statistics>[0] = {}) => (
|
|
41
|
+
sdk.api.endusers.get_engagement_statistics({ range: { from: windowStart }, ...args })
|
|
42
|
+
.then(r => r.count)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// Scenarios are identified by fields.apcCase rather than fname, because fname is title-cased on
|
|
46
|
+
// create ('apc-email' is stored as 'Apc-email') while stored fields are kept verbatim.
|
|
47
|
+
const onlyCase = (apcCase: string) => activeCount({ enduserFields: [{ field: 'fields.apcCase', value: apcCase }] })
|
|
48
|
+
// every enduser this test creates, and nothing else
|
|
49
|
+
const onlyThisTest = (args: Parameters<typeof activeCount>[0] = {}) => activeCount({
|
|
50
|
+
...args, enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// One enduser per scenario, created in a single call to avoid create throttling
|
|
54
|
+
const [
|
|
55
|
+
eEmail, eEmailOutbound,
|
|
56
|
+
eSMS, eSMSOutbound,
|
|
57
|
+
eCallInbound, eCallLongOutbound, eCallShortOutbound,
|
|
58
|
+
eChat, eChatNoEnduser,
|
|
59
|
+
eForm, eFormUnsubmitted, eFormOtherForm,
|
|
60
|
+
eCalendar, eCalendarUserOnly, eCalendarPastStart,
|
|
61
|
+
eGroupMMS, eGroupMMSUserSender,
|
|
62
|
+
eDedupe,
|
|
63
|
+
eCustomType,
|
|
64
|
+
eInactive,
|
|
65
|
+
] = (await sdk.api.endusers.createSome([
|
|
66
|
+
{ fname: 'apc-email', fields: { apcCase: 'email', apcTag: 'yes', apcGroup: 'A' } },
|
|
67
|
+
{ fname: 'apc-email-outbound', fields: { apcCase: 'email-outbound' } },
|
|
68
|
+
{ fname: 'apc-sms', fields: { apcCase: 'sms', apcTag: 'yes', apcGroup: 'A' } },
|
|
69
|
+
{ fname: 'apc-sms-outbound', fields: { apcCase: 'sms-outbound' } },
|
|
70
|
+
{ fname: 'apc-call-inbound', fields: { apcCase: 'call-inbound', apcGroup: 'B' } },
|
|
71
|
+
{ fname: 'apc-call-long-outbound', fields: { apcCase: 'call-long-outbound', apcGroup: 'B' } },
|
|
72
|
+
{ fname: 'apc-call-short-outbound', fields: { apcCase: 'call-short-outbound' } },
|
|
73
|
+
{ fname: 'apc-chat', fields: { apcCase: 'chat', apcGroup: 'B' } },
|
|
74
|
+
{ fname: 'apc-chat-no-enduserid', fields: { apcCase: 'chat-no-enduserid' } },
|
|
75
|
+
{ fname: 'apc-form', fields: { apcCase: 'form', apcGroup: 'C' } },
|
|
76
|
+
{ fname: 'apc-form-unsubmitted', fields: { apcCase: 'form-unsubmitted' } },
|
|
77
|
+
{ fname: 'apc-form-other-form', fields: { apcCase: 'form-other-form', apcGroup: 'C' } },
|
|
78
|
+
// deliberately has no apcGroup, so the $exists case below is narrower than the total
|
|
79
|
+
{ fname: 'apc-calendar', fields: { apcCase: 'calendar' } },
|
|
80
|
+
{ fname: 'apc-calendar-user-only', fields: { apcCase: 'calendar-user-only' } },
|
|
81
|
+
{ fname: 'apc-calendar-past-start', fields: { apcCase: 'calendar-past-start' } },
|
|
82
|
+
{ fname: 'apc-group-mms', fields: { apcCase: 'group-mms', apcGroup: 'D' } },
|
|
83
|
+
{ fname: 'apc-group-mms-user-sender', fields: { apcCase: 'group-mms-user-sender' } },
|
|
84
|
+
{ fname: 'apc-dedupe', fields: { apcCase: 'dedupe', apcTag: 'yes', apcGroup: 'D' } },
|
|
85
|
+
{ fname: 'apc-custom-type', customTypeId: TEST_CUSTOM_TYPE_ID, fields: { apcCase: 'custom-type' } },
|
|
86
|
+
{ fname: 'apc-inactive', fields: { apcCase: 'inactive' } },
|
|
87
|
+
])).created
|
|
88
|
+
|
|
89
|
+
const createdEnduserIds = [
|
|
90
|
+
eEmail, eEmailOutbound, eSMS, eSMSOutbound, eCallInbound, eCallLongOutbound, eCallShortOutbound,
|
|
91
|
+
eChat, eChatNoEnduser, eForm, eFormUnsubmitted, eFormOtherForm, eCalendar, eCalendarUserOnly,
|
|
92
|
+
eCalendarPastStart, eGroupMMS, eGroupMMSUserSender, eDedupe, eCustomType, eInactive,
|
|
93
|
+
].map(e => e.id)
|
|
94
|
+
|
|
95
|
+
const chatRoomIds: string[] = []
|
|
96
|
+
const conversationIds: string[] = []
|
|
97
|
+
const calendarEventIds: string[] = []
|
|
98
|
+
const formIds: string[] = []
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// --- emails ---------------------------------------------------------------------------------
|
|
102
|
+
await sdk.api.emails.createOne({
|
|
103
|
+
logOnly: true, inbound: true, userId,
|
|
104
|
+
enduserId: eEmail.id, subject: 'apc inbound', textContent: 'inbound email',
|
|
105
|
+
})
|
|
106
|
+
// outbound with no link opens does not qualify
|
|
107
|
+
await sdk.api.emails.createOne({
|
|
108
|
+
logOnly: true, inbound: false, userId,
|
|
109
|
+
enduserId: eEmailOutbound.id, subject: 'apc outbound', textContent: 'outbound email',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// --- sms_messages ---------------------------------------------------------------------------
|
|
113
|
+
await sdk.api.sms_messages.createOne({
|
|
114
|
+
logOnly: true, inbound: true, userId,
|
|
115
|
+
enduserId: eSMS.id, message: 'apc inbound sms',
|
|
116
|
+
})
|
|
117
|
+
await sdk.api.sms_messages.createOne({
|
|
118
|
+
logOnly: true, inbound: false, userId,
|
|
119
|
+
enduserId: eSMSOutbound.id, message: 'apc outbound sms',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// --- phone_calls ----------------------------------------------------------------------------
|
|
123
|
+
// inbound qualifies regardless of duration
|
|
124
|
+
await sdk.api.phone_calls.createOne({ enduserId: eCallInbound.id, inbound: true, userId })
|
|
125
|
+
// outbound qualifies only at >= 60 seconds
|
|
126
|
+
await sdk.api.phone_calls.createOne({
|
|
127
|
+
enduserId: eCallLongOutbound.id, inbound: false, userId, callDurationInSeconds: 60,
|
|
128
|
+
})
|
|
129
|
+
await sdk.api.phone_calls.createOne({
|
|
130
|
+
enduserId: eCallShortOutbound.id, inbound: false, userId, callDurationInSeconds: 59,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// --- chats ----------------------------------------------------------------------------------
|
|
134
|
+
const chatRoom = await sdk.api.chat_rooms.createOne({ enduserIds: [eChat.id], userIds: [userId] })
|
|
135
|
+
chatRoomIds.push(chatRoom.id)
|
|
136
|
+
await sdk.api.chats.createOne({
|
|
137
|
+
roomId: chatRoom.id, message: 'apc chat', senderId: eChat.id, enduserId: eChat.id,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// a chat with no enduserId can't be attributed to a patient
|
|
141
|
+
const chatRoomNoEnduser = await sdk.api.chat_rooms.createOne({
|
|
142
|
+
enduserIds: [eChatNoEnduser.id], userIds: [userId],
|
|
143
|
+
})
|
|
144
|
+
chatRoomIds.push(chatRoomNoEnduser.id)
|
|
145
|
+
await sdk.api.chats.createOne({
|
|
146
|
+
roomId: chatRoomNoEnduser.id, message: 'apc chat without enduserId', senderId: userId,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// --- form_responses -------------------------------------------------------------------------
|
|
150
|
+
const formA = await sdk.api.forms.createOne({ title: 'Active Patient Count Form A' })
|
|
151
|
+
formIds.push(formA.id)
|
|
152
|
+
const fieldA = await sdk.api.form_fields.createOne({
|
|
153
|
+
formId: formA.id, type: 'string', title: 'FieldA', previousFields: [{ type: 'root', info: {} }],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const formB = await sdk.api.forms.createOne({ title: 'Active Patient Count Form B' })
|
|
157
|
+
formIds.push(formB.id)
|
|
158
|
+
const fieldB = await sdk.api.form_fields.createOne({
|
|
159
|
+
formId: formB.id, type: 'string', title: 'FieldB', previousFields: [{ type: 'root', info: {} }],
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const submitForm = async (formId: string, fieldId: string, fieldTitle: string, enduserId: string) => {
|
|
163
|
+
const { accessCode } = await sdk.api.form_responses.prepare_form_response({ enduserId, formId })
|
|
164
|
+
await sdk.api.form_responses.submit_form_response({
|
|
165
|
+
accessCode,
|
|
166
|
+
responses: [{ fieldId, fieldTitle, answer: { type: 'string', value: 'apc' } }],
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await submitForm(formA.id, fieldA.id, 'FieldA', eForm.id)
|
|
171
|
+
await submitForm(formB.id, fieldB.id, 'FieldB', eFormOtherForm.id)
|
|
172
|
+
|
|
173
|
+
// prepared but never submitted — no submittedAt, so it must not qualify
|
|
174
|
+
await sdk.api.form_responses.prepare_form_response({
|
|
175
|
+
enduserId: eFormUnsubmitted.id, formId: formA.id,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// --- calendar_events ------------------------------------------------------------------------
|
|
179
|
+
// starts now, not in the future — the bounded-range case below closes the window a couple of
|
|
180
|
+
// seconds out, and calendar_events are filtered on startTimeInMS
|
|
181
|
+
const calendarEvent = await sdk.api.calendar_events.createOne({
|
|
182
|
+
title: 'apc event', durationInMinutes: 30, startTimeInMS: Date.now(),
|
|
183
|
+
attendees: [{ id: eCalendar.id, type: 'enduser' }],
|
|
184
|
+
})
|
|
185
|
+
calendarEventIds.push(calendarEvent.id)
|
|
186
|
+
|
|
187
|
+
const calendarEventUserOnly = await sdk.api.calendar_events.createOne({
|
|
188
|
+
title: 'apc event user only', durationInMinutes: 30, startTimeInMS: Date.now(),
|
|
189
|
+
attendees: [{ id: userId, type: 'user' }],
|
|
190
|
+
})
|
|
191
|
+
calendarEventIds.push(calendarEventUserOnly.id)
|
|
192
|
+
|
|
193
|
+
// created now, but starts 30 days ago — calendar_events filter on startTimeInMS, not _id, so
|
|
194
|
+
// this must fall outside the window even though the record itself is brand new
|
|
195
|
+
const calendarEventPastStart = await sdk.api.calendar_events.createOne({
|
|
196
|
+
title: 'apc event past start', durationInMinutes: 30,
|
|
197
|
+
startTimeInMS: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
198
|
+
attendees: [{ id: eCalendarPastStart.id, type: 'enduser' }],
|
|
199
|
+
})
|
|
200
|
+
calendarEventIds.push(calendarEventPastStart.id)
|
|
201
|
+
|
|
202
|
+
// --- group_mms_conversations ----------------------------------------------------------------
|
|
203
|
+
const conversation = await sdk.api.group_mms_conversations.createOne({
|
|
204
|
+
enduserIds: [eGroupMMS.id, eGroupMMSUserSender.id], userIds: [userId], userStates: [], messages: [],
|
|
205
|
+
})
|
|
206
|
+
conversationIds.push(conversation.id)
|
|
207
|
+
|
|
208
|
+
await sdk.api.group_mms_conversations.send_message({
|
|
209
|
+
conversationId: conversation.id, sender: eGroupMMS.id, message: 'apc group mms', logOnly: true,
|
|
210
|
+
})
|
|
211
|
+
await wait(undefined, 250)
|
|
212
|
+
// sent by a user, so it counts for nobody — the other enduser in the conversation included
|
|
213
|
+
await sdk.api.group_mms_conversations.send_message({
|
|
214
|
+
conversationId: conversation.id, sender: userId, message: 'apc group mms from user', logOnly: true,
|
|
215
|
+
})
|
|
216
|
+
await wait(undefined, 250)
|
|
217
|
+
|
|
218
|
+
// --- dedupe: one enduser active across three channels ----------------------------------------
|
|
219
|
+
await sdk.api.emails.createOne({
|
|
220
|
+
logOnly: true, inbound: true, userId,
|
|
221
|
+
enduserId: eDedupe.id, subject: 'apc dedupe', textContent: 'inbound email',
|
|
222
|
+
})
|
|
223
|
+
await sdk.api.sms_messages.createOne({
|
|
224
|
+
logOnly: true, inbound: true, userId, enduserId: eDedupe.id, message: 'apc dedupe sms',
|
|
225
|
+
})
|
|
226
|
+
const dedupeRoom = await sdk.api.chat_rooms.createOne({ enduserIds: [eDedupe.id], userIds: [userId] })
|
|
227
|
+
chatRoomIds.push(dedupeRoom.id)
|
|
228
|
+
await sdk.api.chats.createOne({
|
|
229
|
+
roomId: dedupeRoom.id, message: 'apc dedupe chat', senderId: eDedupe.id, enduserId: eDedupe.id,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// --- customTypeId: excluded from the default count --------------------------------------------
|
|
233
|
+
await sdk.api.emails.createOne({
|
|
234
|
+
logOnly: true, inbound: true, userId,
|
|
235
|
+
enduserId: eCustomType.id, subject: 'apc custom type', textContent: 'inbound email',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await wait(undefined, 1000) // let side effects settle
|
|
239
|
+
|
|
240
|
+
// Qualifying endusers: email, sms, inbound call, long outbound call, chat, form A, form B,
|
|
241
|
+
// calendar, group mms, dedupe = 10. eCustomType is excluded by customTypeId.
|
|
242
|
+
const EXPECTED_ACTIVE = 10
|
|
243
|
+
|
|
244
|
+
await async_test(
|
|
245
|
+
"Active count includes exactly the qualifying endusers",
|
|
246
|
+
() => onlyThisTest(),
|
|
247
|
+
{ onResult: r => r === EXPECTED_ACTIVE }
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// --- dedupe ----------------------------------------------------------------------------------
|
|
251
|
+
await async_test(
|
|
252
|
+
"Enduser active on email + sms + chat counts once",
|
|
253
|
+
() => onlyCase('dedupe'),
|
|
254
|
+
{ onResult: r => r === 1 }
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// --- per-channel attribution -----------------------------------------------------------------
|
|
258
|
+
const expectActive = (label: string, apcCase: string, expected: number) => async_test(
|
|
259
|
+
label,
|
|
260
|
+
() => onlyCase(apcCase),
|
|
261
|
+
{ onResult: r => r === expected }
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
await expectActive("Inbound email counts", 'email', 1)
|
|
265
|
+
await expectActive("Outbound email without link opens does not count", 'email-outbound', 0)
|
|
266
|
+
await expectActive("Inbound SMS counts", 'sms', 1)
|
|
267
|
+
await expectActive("Outbound SMS does not count", 'sms-outbound', 0)
|
|
268
|
+
await expectActive("Inbound phone call counts", 'call-inbound', 1)
|
|
269
|
+
await expectActive("Outbound phone call >= 60s counts", 'call-long-outbound', 1)
|
|
270
|
+
await expectActive("Outbound phone call < 60s does not count", 'call-short-outbound', 0)
|
|
271
|
+
await expectActive("Chat with enduserId counts", 'chat', 1)
|
|
272
|
+
await expectActive("Chat without enduserId does not count", 'chat-no-enduserid', 0)
|
|
273
|
+
await expectActive("Submitted form response counts", 'form', 1)
|
|
274
|
+
await expectActive("Unsubmitted form response does not count", 'form-unsubmitted', 0)
|
|
275
|
+
await expectActive("Calendar event with enduser attendee counts", 'calendar', 1)
|
|
276
|
+
await expectActive("Calendar event with only user attendees does not count", 'calendar-user-only', 0)
|
|
277
|
+
await expectActive("Inbound group MMS counts", 'group-mms', 1)
|
|
278
|
+
await expectActive("Group MMS sent by a user does not count", 'group-mms-user-sender', 0)
|
|
279
|
+
await expectActive("Enduser with no engagement does not count", 'inactive', 0)
|
|
280
|
+
|
|
281
|
+
// --- date field per collection ----------------------------------------------------------------
|
|
282
|
+
await async_test(
|
|
283
|
+
"Calendar event is filtered by startTimeInMS, not creation time",
|
|
284
|
+
() => onlyCase('calendar-past-start'),
|
|
285
|
+
{ onResult: r => r === 0 }
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// --- range boundaries -------------------------------------------------------------------------
|
|
289
|
+
const afterAll = new Date(Date.now() + 2000)
|
|
290
|
+
|
|
291
|
+
await async_test(
|
|
292
|
+
"Active count is unchanged with an explicit upper bound after all activity",
|
|
293
|
+
() => sdk.api.endusers.get_engagement_statistics({
|
|
294
|
+
range: { from: windowStart, to: afterAll },
|
|
295
|
+
enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
|
|
296
|
+
}).then(r => r.count),
|
|
297
|
+
{ onResult: r => r === EXPECTED_ACTIVE }
|
|
298
|
+
)
|
|
299
|
+
await async_test(
|
|
300
|
+
"Active count is 0 for a range that starts after all activity",
|
|
301
|
+
() => sdk.api.endusers.get_engagement_statistics({ range: { from: afterAll } }).then(r => r.count),
|
|
302
|
+
{ onResult: r => r === 0 }
|
|
303
|
+
)
|
|
304
|
+
await async_test(
|
|
305
|
+
"Active count is 0 for a range that ends before all activity",
|
|
306
|
+
() => sdk.api.endusers.get_engagement_statistics({
|
|
307
|
+
range: { from: new Date(windowStart.getTime() - 4000), to: windowStart },
|
|
308
|
+
enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
|
|
309
|
+
}).then(r => r.count),
|
|
310
|
+
{ onResult: r => r === 0 }
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// --- customTypeId ------------------------------------------------------------------------------
|
|
314
|
+
await async_test(
|
|
315
|
+
"Endusers with a customTypeId are excluded by default",
|
|
316
|
+
() => onlyCase('custom-type'),
|
|
317
|
+
{ onResult: r => r === 0 }
|
|
318
|
+
)
|
|
319
|
+
await async_test(
|
|
320
|
+
"Endusers with a customTypeId are included when that customTypeId is requested",
|
|
321
|
+
() => activeCount({ customTypeId: TEST_CUSTOM_TYPE_ID }),
|
|
322
|
+
{ onResult: r => r === 1 }
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
// --- formIds ------------------------------------------------------------------------------------
|
|
326
|
+
await async_test(
|
|
327
|
+
"formIds narrows which form responses qualify",
|
|
328
|
+
() => onlyThisTest({ formIds: [formA.id] }),
|
|
329
|
+
{ onResult: r => r === EXPECTED_ACTIVE - 1 } // drops the enduser who only submitted form B
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
// --- endusersFilter (a separate code path from enduserFields) -------------------------------------
|
|
333
|
+
await async_test(
|
|
334
|
+
"endusersFilter narrows the counted endusers",
|
|
335
|
+
() => activeCount({ endusersFilter: { 'fields.apcCase': 'email' } }),
|
|
336
|
+
{ onResult: r => r === 1 }
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// --- enduserFields -------------------------------------------------------------------------------
|
|
340
|
+
await async_test(
|
|
341
|
+
"enduserFields matches on a stored field value",
|
|
342
|
+
() => activeCount({ enduserFields: [{ field: 'fields.apcTag', value: 'yes' }] }),
|
|
343
|
+
{ onResult: r => r === 3 } // email, sms, dedupe
|
|
344
|
+
)
|
|
345
|
+
await async_test(
|
|
346
|
+
"enduserFields supports the $exists qualifier",
|
|
347
|
+
() => activeCount({ enduserFields: [{ field: 'fields.apcGroup', value: '$exists' }] }),
|
|
348
|
+
{ onResult: r => r === EXPECTED_ACTIVE - 1 } // all active endusers except apc-calendar
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// --- groupBy ---------------------------------------------------------------------------------------
|
|
352
|
+
await async_test(
|
|
353
|
+
"groupBy returns per-group counts summing to the total",
|
|
354
|
+
() => sdk.api.endusers.get_engagement_statistics({
|
|
355
|
+
range: { from: windowStart }, groupBy: 'fields.apcGroup',
|
|
356
|
+
enduserFields: [{ field: 'fields.apcCase', value: '$exists' }],
|
|
357
|
+
}),
|
|
358
|
+
{
|
|
359
|
+
onResult: r => (
|
|
360
|
+
Array.isArray(r.grouped)
|
|
361
|
+
&& r.grouped.reduce((sum, g) => sum + g.count, 0) === EXPECTED_ACTIVE
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// --- endusers cannot read org-wide staff analytics ------------------------------------------------
|
|
367
|
+
const enduserSDK = new EnduserSession({ host, businessId })
|
|
368
|
+
await authenticate_enduser_via_token(sdk, enduserSDK, { id: eEmail.id })
|
|
369
|
+
|
|
370
|
+
await async_test(
|
|
371
|
+
"Endusers cannot call get_engagement_statistics",
|
|
372
|
+
() => enduserSDK._GET('/v1/endusers/engagement', { range: { from: windowStart } }),
|
|
373
|
+
handleAnyError
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
// --- tenant isolation --------------------------------------------------------------------------------
|
|
377
|
+
// The refactor drops restrictAccess in favor of a direct businessId match, so this is the one
|
|
378
|
+
// boundary the function now enforces entirely on its own.
|
|
379
|
+
const sdkOther = new Session({ host, apiKey: OTHER_TENANT_API_KEY })
|
|
380
|
+
const otherWindowStart = new Date(Date.now() - 2000)
|
|
381
|
+
const otherEnduser = await sdkOther.api.endusers.createOne({ fname: 'apc-other-tenant' })
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
// userId is left to its session initializer — this is an API-key session with no logged-in user
|
|
385
|
+
await sdkOther.api.emails.createOne({
|
|
386
|
+
logOnly: true, inbound: true,
|
|
387
|
+
enduserId: otherEnduser.id, subject: 'apc other tenant', textContent: 'inbound email',
|
|
388
|
+
})
|
|
389
|
+
await wait(undefined, 500)
|
|
390
|
+
|
|
391
|
+
await async_test(
|
|
392
|
+
"Other tenant's engagement does not appear in this tenant's count",
|
|
393
|
+
() => onlyThisTest(),
|
|
394
|
+
{ onResult: r => r === EXPECTED_ACTIVE }
|
|
395
|
+
)
|
|
396
|
+
await async_test(
|
|
397
|
+
"This tenant's engagement does not appear in the other tenant's count",
|
|
398
|
+
() => sdkOther.api.endusers.get_engagement_statistics({ range: { from: otherWindowStart } })
|
|
399
|
+
.then(r => r.count),
|
|
400
|
+
{ onResult: r => r === 1 }
|
|
401
|
+
)
|
|
402
|
+
} finally {
|
|
403
|
+
await sdkOther.api.endusers.deleteOne(otherEnduser.id).catch(console.error)
|
|
404
|
+
}
|
|
405
|
+
} finally {
|
|
406
|
+
await Promise.all([
|
|
407
|
+
...createdEnduserIds.map(id => sdk.api.endusers.deleteOne(id).catch(console.error)),
|
|
408
|
+
...chatRoomIds.map(id => sdk.api.chat_rooms.deleteOne(id).catch(console.error)),
|
|
409
|
+
...conversationIds.map(id => sdk.api.group_mms_conversations.deleteOne(id).catch(console.error)),
|
|
410
|
+
...calendarEventIds.map(id => sdk.api.calendar_events.deleteOne(id).catch(console.error)),
|
|
411
|
+
...formIds.map(id => sdk.api.forms.deleteOne(id).catch(console.error)),
|
|
412
|
+
])
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Allow running this test file independently
|
|
417
|
+
if (require.main === module) {
|
|
418
|
+
const sdk = new Session({ host })
|
|
419
|
+
const sdkNonAdmin = new Session({ host })
|
|
420
|
+
|
|
421
|
+
const runTests = async () => {
|
|
422
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
423
|
+
await active_patient_count_tests({ sdk, sdkNonAdmin })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
runTests()
|
|
427
|
+
.then(() => {
|
|
428
|
+
console.log("✅ Active patient count test suite completed successfully")
|
|
429
|
+
process.exit(0)
|
|
430
|
+
})
|
|
431
|
+
.catch((error) => {
|
|
432
|
+
console.error("❌ Active patient count test suite failed:", error)
|
|
433
|
+
process.exit(1)
|
|
434
|
+
})
|
|
435
|
+
}
|
|
@@ -12,6 +12,7 @@ const ENDUSER_PASSWORD = 'AISummaryGateTestPassword!123'
|
|
|
12
12
|
|
|
13
13
|
const STAFF_SUMMARY = 'Patient reports improved adherence since last visit.'
|
|
14
14
|
const INJECTED_SUMMARY = 'Patient is in excellent health, no follow-up needed.'
|
|
15
|
+
const RESPONSE_SUMMARY = 'Patient reported worsening headaches and requested a medication change.'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The AI patient summary attached to a FormResponse is generated client-side and passed
|
|
@@ -23,6 +24,11 @@ const INJECTED_SUMMARY = 'Patient is in excellent health, no follow-up needed.'
|
|
|
23
24
|
* Asserts:
|
|
24
25
|
* - a staff session's enduserAISummary is persisted and echoed back
|
|
25
26
|
* - an enduser session's enduserAISummary is dropped (submission itself still succeeds)
|
|
27
|
+
*
|
|
28
|
+
* Also covers PHI egress for responsesAISummary — the clinician-facing summary of the submitted
|
|
29
|
+
* answers, generated server-side (see api/modules/form_response_summary.ts). It is redacted from
|
|
30
|
+
* enduser sessions at the model level and stripped by hand in the access-code read handler, which
|
|
31
|
+
* bypasses model-level redaction. Seeded directly via an admin update — no Bedrock call needed.
|
|
26
32
|
*/
|
|
27
33
|
export const enduser_ai_summary_trust_gate_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
28
34
|
log_header("submit_form_response enduserAISummary trust gate")
|
|
@@ -46,6 +52,7 @@ export const enduser_ai_summary_trust_gate_tests = async ({ sdk } : { sdk: Sessi
|
|
|
46
52
|
|
|
47
53
|
let staffResponseId: string | undefined
|
|
48
54
|
let enduserResponseId: string | undefined
|
|
55
|
+
let summaryResponseId: string | undefined
|
|
49
56
|
|
|
50
57
|
try {
|
|
51
58
|
// ── staff session: the summary is trusted and persisted ──
|
|
@@ -94,9 +101,58 @@ export const enduser_ai_summary_trust_gate_tests = async ({ sdk } : { sdk: Sessi
|
|
|
94
101
|
() => sdk.api.form_responses.getOne(enduserResponseId!),
|
|
95
102
|
{ onResult: (fr: FormResponse) => fr.enduserAISummary === undefined },
|
|
96
103
|
)
|
|
104
|
+
|
|
105
|
+
// ── responsesAISummary: clinician-facing, never returned to the patient ──
|
|
106
|
+
// seeded by an admin update rather than a real submission, so no AI call is made
|
|
107
|
+
const { accessCode: summaryAccessCode } = await sdk.api.form_responses.prepare_form_response({
|
|
108
|
+
enduserId: enduser.id, formId: form.id,
|
|
109
|
+
})
|
|
110
|
+
const summaryResponse = await enduserSDK.api.form_responses.submit_form_response({
|
|
111
|
+
accessCode: summaryAccessCode,
|
|
112
|
+
responses,
|
|
113
|
+
})
|
|
114
|
+
summaryResponseId = summaryResponse.formResponse.id
|
|
115
|
+
await sdk.api.form_responses.updateOne(summaryResponseId, {
|
|
116
|
+
responsesAISummary: RESPONSE_SUMMARY,
|
|
117
|
+
responsesAISummaryGeneratedAt: new Date(),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await async_test(
|
|
121
|
+
"staff session reads responsesAISummary",
|
|
122
|
+
() => sdk.api.form_responses.getOne(summaryResponseId!),
|
|
123
|
+
{ onResult: (fr: FormResponse) => fr.responsesAISummary === RESPONSE_SUMMARY },
|
|
124
|
+
)
|
|
125
|
+
await async_test(
|
|
126
|
+
"enduser session never receives responsesAISummary",
|
|
127
|
+
() => enduserSDK.api.form_responses.getOne(summaryResponseId!),
|
|
128
|
+
{ onResult: (fr: FormResponse) => (
|
|
129
|
+
fr.responsesAISummary === undefined && fr.responsesAISummaryGeneratedAt === undefined
|
|
130
|
+
)},
|
|
131
|
+
)
|
|
132
|
+
await async_test(
|
|
133
|
+
"enduser readMany never receives responsesAISummary",
|
|
134
|
+
() => enduserSDK.api.form_responses.getSome({ ids: [summaryResponseId!] }),
|
|
135
|
+
{ onResult: (frs: FormResponse[]) => frs.every(fr => (
|
|
136
|
+
fr.responsesAISummary === undefined && fr.responsesAISummaryGeneratedAt === undefined
|
|
137
|
+
))},
|
|
138
|
+
)
|
|
139
|
+
// the access-code read handler bypasses model-level redaction, so it strips by hand
|
|
140
|
+
await async_test(
|
|
141
|
+
"public access-code read never receives responsesAISummary",
|
|
142
|
+
() => enduserSDK.api.form_responses.info_for_access_code({ accessCode: summaryAccessCode }),
|
|
143
|
+
{ onResult: ({ response }: { response: FormResponse }) => (
|
|
144
|
+
response.responsesAISummary === undefined && response.responsesAISummaryGeneratedAt === undefined
|
|
145
|
+
)},
|
|
146
|
+
)
|
|
147
|
+
await async_test(
|
|
148
|
+
"staff access-code read still receives responsesAISummary",
|
|
149
|
+
() => sdk.api.form_responses.info_for_access_code({ accessCode: summaryAccessCode }),
|
|
150
|
+
{ onResult: ({ response }: { response: FormResponse }) => response.responsesAISummary === RESPONSE_SUMMARY },
|
|
151
|
+
)
|
|
97
152
|
} finally {
|
|
98
153
|
if (staffResponseId) { try { await sdk.api.form_responses.deleteOne(staffResponseId) } catch (err) { /* ignore */ } }
|
|
99
154
|
if (enduserResponseId) { try { await sdk.api.form_responses.deleteOne(enduserResponseId) } catch (err) { /* ignore */ } }
|
|
155
|
+
if (summaryResponseId) { try { await sdk.api.form_responses.deleteOne(summaryResponseId) } catch (err) { /* ignore */ } }
|
|
100
156
|
try { await sdk.api.endusers.deleteOne(enduser.id) } catch (err) { /* ignore */ }
|
|
101
157
|
try { await sdk.api.forms.deleteOne(form.id) } catch (err) { /* ignore */ }
|
|
102
158
|
}
|