@tellescope/sdk 1.256.6 → 1.256.8
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/enduser.d.ts +1 -1
- package/lib/cjs/enduser.d.ts.map +1 -1
- package/lib/cjs/enduser.js +1 -0
- package/lib/cjs/enduser.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +505 -0
- package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.js +173 -0
- package/lib/cjs/tests/api_tests/configurations_public_read.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/form_started_trigger.test.js +87 -24
- package/lib/cjs/tests/api_tests/form_started_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/form_translations.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/form_translations.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/form_translations.test.js +259 -0
- package/lib/cjs/tests/api_tests/form_translations.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js +293 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
- package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js +277 -0
- package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js +262 -78
- package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +315 -0
- package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +343 -80
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js +74 -35
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -1
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts +134 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -1
- package/lib/cjs/tests/stripe_webhook_helpers.js +126 -1
- package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +242 -212
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +2 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/enduser.js +1 -0
- package/lib/esm/enduser.js.map +1 -1
- package/lib/esm/sdk.d.ts +3 -2
- package/lib/esm/sdk.d.ts.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/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +501 -0
- package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.js +169 -0
- package/lib/esm/tests/api_tests/configurations_public_read.test.js.map +1 -0
- package/lib/esm/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/form_started_trigger.test.js +87 -24
- package/lib/esm/tests/api_tests/form_started_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/form_translations.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/form_translations.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/form_translations.test.js +255 -0
- package/lib/esm/tests/api_tests/form_translations.test.js.map +1 -0
- package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js +289 -0
- package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
- package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js +273 -0
- package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.js +262 -78
- package/lib/esm/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +311 -0
- package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +344 -81
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/welle_partner_token.test.js +74 -35
- package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -1
- package/lib/esm/tests/stripe_webhook_helpers.d.ts +134 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -1
- package/lib/esm/tests/stripe_webhook_helpers.js +119 -0
- package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +242 -212
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/enduser.ts +4 -2
- package/src/tests/api_tests/calendar_event_create_and_bulk_triggers.test.ts +293 -0
- package/src/tests/api_tests/configurations_public_read.test.ts +116 -0
- package/src/tests/api_tests/form_started_trigger.test.ts +49 -1
- package/src/tests/api_tests/form_translations.test.ts +205 -0
- package/src/tests/api_tests/group_event_attendee_self_update.test.ts +161 -0
- package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +135 -0
- package/src/tests/api_tests/mdi_trigger_refill.test.ts +184 -0
- package/src/tests/api_tests/purchase_made_trigger.test.ts +137 -0
- package/src/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.ts +204 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +16 -6
- package/src/tests/api_tests/stripe_webhook_triggers.test.ts +221 -0
- package/src/tests/api_tests/welle_partner_token.test.ts +49 -6
- package/src/tests/stripe_webhook_helpers.ts +116 -0
- package/src/tests/tests.ts +29 -12
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert,
|
|
6
|
+
async_test,
|
|
7
|
+
handleAnyError,
|
|
8
|
+
log_header,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import {
|
|
11
|
+
decodeTranslationMapFromStorage,
|
|
12
|
+
encodeTranslationMapForStorage,
|
|
13
|
+
objects_equivalent,
|
|
14
|
+
} from "@tellescope/utilities"
|
|
15
|
+
import { setup_tests } from "../setup"
|
|
16
|
+
|
|
17
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
18
|
+
|
|
19
|
+
// Modular test for AI form translations (display-language maps for v2 forms).
|
|
20
|
+
//
|
|
21
|
+
// Covers:
|
|
22
|
+
// 1. Storage round-trip: encodeTranslationMapForStorage survives the configurations.value escapeHTML
|
|
23
|
+
// validator (sanitize_html strips tags and entity-encodes & < >) for maps containing HTML, & < >,
|
|
24
|
+
// quotes, {{placeholders}}, and non-ASCII text.
|
|
25
|
+
// 2. Form.translationConfigurations (language -> configurationId) persistence.
|
|
26
|
+
// 3. public_form_details language param: returns the map only for a publicRead-flagged configuration
|
|
27
|
+
// on a form that allows a public URL.
|
|
28
|
+
// 4. submit_form_response viewedInLanguage: persisted as metadata while stored answers stay English.
|
|
29
|
+
//
|
|
30
|
+
// NOTE: this test deliberately does NOT invoke the AI translation endpoint (ai_conversations.send_message) —
|
|
31
|
+
// that path consumes AI credits. Map generation is exercised via manual map fixtures.
|
|
32
|
+
export const form_translations_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
33
|
+
log_header("Form Translations Tests")
|
|
34
|
+
|
|
35
|
+
const ENGLISH_CHOICES = ['Salt & Pepper', '<b>Bold</b> choice', `It's "quoted"`, 'Résumé 中文']
|
|
36
|
+
|
|
37
|
+
// keys AND values include the characters sanitize_html would otherwise mangle
|
|
38
|
+
const TRANSLATION_MAP: Record<string, string> = {
|
|
39
|
+
'What is your favorite seasoning?': '¿Cuál es tu condimento favorito?',
|
|
40
|
+
'Salt & Pepper': 'Sal & Pimienta',
|
|
41
|
+
'<b>Bold</b> choice': '<b>Negrita</b> elección',
|
|
42
|
+
[`It's "quoted"`]: `Está "citado"`,
|
|
43
|
+
'Résumé 中文': 'Currículum 中文',
|
|
44
|
+
'Hello {{enduser.fname}} <br/> welcome': 'Hola {{enduser.fname}} <br/> bienvenido',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const createdConfigurationIds: string[] = []
|
|
48
|
+
let formId: string | undefined
|
|
49
|
+
let fieldId: string | undefined
|
|
50
|
+
let enduserId: string | undefined
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// --- 1. escapeHTML-safe storage round-trip (run first: it guards the whole storage design) ---
|
|
54
|
+
const encoded = encodeTranslationMapForStorage(TRANSLATION_MAP)
|
|
55
|
+
const configuration = await sdk.api.configurations.createOne({
|
|
56
|
+
type: 'form-translations:test:es',
|
|
57
|
+
value: encoded,
|
|
58
|
+
publicRead: true,
|
|
59
|
+
})
|
|
60
|
+
createdConfigurationIds.push(configuration.id)
|
|
61
|
+
|
|
62
|
+
const storedConfiguration = await sdk.api.configurations.getOne(configuration.id)
|
|
63
|
+
const decoded = decodeTranslationMapFromStorage(storedConfiguration.value)
|
|
64
|
+
assert(
|
|
65
|
+
objects_equivalent(decoded, TRANSLATION_MAP),
|
|
66
|
+
`translation map corrupted by storage: ${JSON.stringify(decoded)}`,
|
|
67
|
+
'translation map with & < > quotes HTML and non-ASCII survives storage round-trip',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// an unflagged configuration, to verify the publicRead gate on public_form_details
|
|
71
|
+
const unflaggedConfiguration = await sdk.api.configurations.createOne({
|
|
72
|
+
type: 'form-translations:test:fr-unflagged',
|
|
73
|
+
value: encodeTranslationMapForStorage({ Hello: 'Bonjour' }),
|
|
74
|
+
})
|
|
75
|
+
createdConfigurationIds.push(unflaggedConfiguration.id)
|
|
76
|
+
|
|
77
|
+
// --- 2. form mapping persistence ---
|
|
78
|
+
const form = await sdk.api.forms.createOne({
|
|
79
|
+
title: 'Form Translations Test Form',
|
|
80
|
+
version: 'v2',
|
|
81
|
+
allowPublicURL: true,
|
|
82
|
+
})
|
|
83
|
+
formId = form.id
|
|
84
|
+
|
|
85
|
+
const field = await sdk.api.form_fields.createOne({
|
|
86
|
+
formId: form.id,
|
|
87
|
+
title: 'What is your favorite seasoning?',
|
|
88
|
+
type: 'multiple_choice',
|
|
89
|
+
options: { choices: ENGLISH_CHOICES, radio: true },
|
|
90
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
91
|
+
})
|
|
92
|
+
fieldId = field.id
|
|
93
|
+
|
|
94
|
+
await sdk.api.forms.updateOne(form.id, {
|
|
95
|
+
translationConfigurations: [
|
|
96
|
+
{ language: 'es', configurationId: configuration.id },
|
|
97
|
+
{ language: 'fr', configurationId: unflaggedConfiguration.id },
|
|
98
|
+
],
|
|
99
|
+
}, { replaceObjectFields: true })
|
|
100
|
+
|
|
101
|
+
const updatedForm = await sdk.api.forms.getOne(form.id)
|
|
102
|
+
assert(
|
|
103
|
+
updatedForm.translationConfigurations?.length === 2
|
|
104
|
+
&& updatedForm.translationConfigurations.some(t => t.language === 'es' && t.configurationId === configuration.id),
|
|
105
|
+
`translationConfigurations not persisted: ${JSON.stringify(updatedForm.translationConfigurations)}`,
|
|
106
|
+
'form translationConfigurations mapping persists',
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// --- 3. public_form_details language param (unauthenticated) ---
|
|
110
|
+
const publicSDK = new EnduserSession({ host, businessId: sdk.userInfo.businessId })
|
|
111
|
+
|
|
112
|
+
await async_test(
|
|
113
|
+
'public_form_details returns translations for a mapped language with a flagged configuration',
|
|
114
|
+
() => publicSDK.api.forms.public_form_details({ formId: form.id, language: 'es' }),
|
|
115
|
+
{ onResult: r => objects_equivalent(r.translations, TRANSLATION_MAP) },
|
|
116
|
+
)
|
|
117
|
+
await async_test(
|
|
118
|
+
'public_form_details returns no translations without a language param',
|
|
119
|
+
() => publicSDK.api.forms.public_form_details({ formId: form.id }),
|
|
120
|
+
{ onResult: r => !r.translations || Object.keys(r.translations).length === 0 },
|
|
121
|
+
)
|
|
122
|
+
await async_test(
|
|
123
|
+
'public_form_details returns no translations for an unmapped language',
|
|
124
|
+
() => publicSDK.api.forms.public_form_details({ formId: form.id, language: 'de' }),
|
|
125
|
+
{ onResult: r => !r.translations || Object.keys(r.translations).length === 0 },
|
|
126
|
+
)
|
|
127
|
+
await async_test(
|
|
128
|
+
'public_form_details returns no translations for an UNFLAGGED configuration (publicRead gate)',
|
|
129
|
+
() => publicSDK.api.forms.public_form_details({ formId: form.id, language: 'fr' }),
|
|
130
|
+
{ onResult: r => !r.translations || Object.keys(r.translations).length === 0 },
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// a form without allowPublicURL still rejects public_form_details entirely
|
|
134
|
+
await sdk.api.forms.updateOne(form.id, { allowPublicURL: false })
|
|
135
|
+
await async_test(
|
|
136
|
+
'public_form_details still rejects forms without allowPublicURL',
|
|
137
|
+
() => publicSDK.api.forms.public_form_details({ formId: form.id, language: 'es' }),
|
|
138
|
+
handleAnyError,
|
|
139
|
+
)
|
|
140
|
+
await sdk.api.forms.updateOne(form.id, { allowPublicURL: true })
|
|
141
|
+
|
|
142
|
+
// --- 4. viewedInLanguage persists; stored answers remain English ---
|
|
143
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
144
|
+
email: `form_translations_${Date.now()}@test.tellescope.com`,
|
|
145
|
+
})
|
|
146
|
+
enduserId = enduser.id
|
|
147
|
+
|
|
148
|
+
const { accessCode } = await sdk.api.form_responses.prepare_form_response({
|
|
149
|
+
formId: form.id, enduserId: enduser.id,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const { formResponse } = await sdk.api.form_responses.submit_form_response({
|
|
153
|
+
accessCode,
|
|
154
|
+
viewedInLanguage: 'es',
|
|
155
|
+
responses: [{
|
|
156
|
+
fieldId: field.id,
|
|
157
|
+
fieldTitle: field.title, // English snapshot, as the client submits it
|
|
158
|
+
answer: { type: 'multiple_choice', value: ['Salt & Pepper'] }, // English source value, never the translation
|
|
159
|
+
}],
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const storedResponse = await sdk.api.form_responses.getOne(formResponse.id)
|
|
163
|
+
assert(
|
|
164
|
+
storedResponse.viewedInLanguage === 'es',
|
|
165
|
+
`viewedInLanguage not persisted: ${storedResponse.viewedInLanguage}`,
|
|
166
|
+
'viewedInLanguage persists on the submitted form response',
|
|
167
|
+
)
|
|
168
|
+
const storedAnswer = storedResponse.responses?.find(r => r.fieldId === field.id)
|
|
169
|
+
assert(
|
|
170
|
+
Array.isArray(storedAnswer?.answer?.value)
|
|
171
|
+
&& (storedAnswer!.answer.value as string[])[0] === 'Salt & Pepper'
|
|
172
|
+
&& storedAnswer!.fieldTitle === 'What is your favorite seasoning?',
|
|
173
|
+
`stored response not English: ${JSON.stringify(storedAnswer)}`,
|
|
174
|
+
'stored answer value and fieldTitle remain the English source strings',
|
|
175
|
+
)
|
|
176
|
+
} finally {
|
|
177
|
+
if (enduserId) await sdk.api.endusers.deleteOne(enduserId).catch(() => {})
|
|
178
|
+
if (fieldId) await sdk.api.form_fields.deleteOne(fieldId).catch(() => {})
|
|
179
|
+
if (formId) await sdk.api.forms.deleteOne(formId).catch(() => {})
|
|
180
|
+
for (const id of createdConfigurationIds) {
|
|
181
|
+
await sdk.api.configurations.deleteOne(id).catch(() => {})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Allow running this test file independently
|
|
187
|
+
if (require.main === module) {
|
|
188
|
+
const sdk = new Session({ host })
|
|
189
|
+
const sdkNonAdmin = new Session({ host })
|
|
190
|
+
|
|
191
|
+
const runTests = async () => {
|
|
192
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
193
|
+
await form_translations_tests({ sdk, sdkNonAdmin })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
runTests()
|
|
197
|
+
.then(() => {
|
|
198
|
+
console.log("✅ Form translations test suite completed successfully")
|
|
199
|
+
process.exit(0)
|
|
200
|
+
})
|
|
201
|
+
.catch((error) => {
|
|
202
|
+
console.error("❌ Form translations test suite failed:", error)
|
|
203
|
+
process.exit(1)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session, EnduserSession } from "../../sdk"
|
|
4
|
+
import { async_test, handleAnyError, passOnAnyResult, log_header } from "@tellescope/testing"
|
|
5
|
+
import { setup_tests } from "../setup"
|
|
6
|
+
|
|
7
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
8
|
+
|
|
9
|
+
// An enduser attendee can PATCH a calendar_event (needed to self-cancel out of a group event), and
|
|
10
|
+
// attendeeStatuses / cancelledGroupAttendees are attendee-keyed arrays that drive per-attendee
|
|
11
|
+
// automation triggers (handle_attendee_status_change_triggers + event-wide completion). The
|
|
12
|
+
// calendar_events relationship constraint must let an enduser touch ONLY their own id's entry, so a
|
|
13
|
+
// patient cannot fire or suppress a co-attendee's Appointment triggers by patching the shared event.
|
|
14
|
+
export const group_event_attendee_self_update_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
15
|
+
log_header("Group Event Attendee Self-Update Constraint")
|
|
16
|
+
|
|
17
|
+
const createdEnduserIds: string[] = []
|
|
18
|
+
const createdEventIds: string[] = []
|
|
19
|
+
|
|
20
|
+
const createEnduser = async (label: string) => {
|
|
21
|
+
const e = await sdk.api.endusers.createOne({ email: `attendee_self_update_${label}_${Date.now()}@test.tellescope.com` })
|
|
22
|
+
createdEnduserIds.push(e.id)
|
|
23
|
+
return e
|
|
24
|
+
}
|
|
25
|
+
const enduserSession = async (id: string) => {
|
|
26
|
+
const { authToken, enduser } = await sdk.api.endusers.generate_auth_token({ id })
|
|
27
|
+
return new EnduserSession({ host, authToken, enduser, businessId: sdk.userInfo.businessId })
|
|
28
|
+
}
|
|
29
|
+
const createGroupEvent = async (attendeeIds: string[]) => {
|
|
30
|
+
const event = await sdk.api.calendar_events.createOne({
|
|
31
|
+
title: 'Attendee Self-Update Test',
|
|
32
|
+
durationInMinutes: 30,
|
|
33
|
+
startTimeInMS: Date.now() + Math.floor(Math.random() * 100000),
|
|
34
|
+
attendees: attendeeIds.map(id => ({ type: 'enduser' as const, id })),
|
|
35
|
+
})
|
|
36
|
+
createdEventIds.push(event.id)
|
|
37
|
+
return event
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const attacker = await createEnduser('self')
|
|
42
|
+
const victim = await createEnduser('other')
|
|
43
|
+
const attackerSDK = await enduserSession(attacker.id)
|
|
44
|
+
|
|
45
|
+
// ---- ALLOWED: self-cancel out of a fresh group event (the portal self-cancel path) ----
|
|
46
|
+
const e1 = await createGroupEvent([attacker.id, victim.id])
|
|
47
|
+
await async_test(
|
|
48
|
+
"enduser may add their OWN cancelledGroupAttendees entry (self-cancel)",
|
|
49
|
+
() => attackerSDK.api.calendar_events.updateOne(e1.id, {
|
|
50
|
+
cancelledGroupAttendees: [{ id: attacker.id, at: new Date() }],
|
|
51
|
+
}, { replaceObjectFields: true }),
|
|
52
|
+
passOnAnyResult,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// ---- ALLOWED: set own attendeeStatuses entry ----
|
|
56
|
+
const e2 = await createGroupEvent([attacker.id, victim.id])
|
|
57
|
+
await async_test(
|
|
58
|
+
"enduser may set their OWN attendeeStatuses entry",
|
|
59
|
+
() => attackerSDK.api.calendar_events.updateOne(e2.id, {
|
|
60
|
+
attendeeStatuses: [{ id: attacker.id, at: new Date(), status: 'Cancelled' }],
|
|
61
|
+
}, { replaceObjectFields: true }),
|
|
62
|
+
passOnAnyResult,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// ---- REJECTED: set a co-attendee's attendeeStatuses (would fire that patient's triggers) ----
|
|
66
|
+
const e3 = await createGroupEvent([attacker.id, victim.id])
|
|
67
|
+
await async_test(
|
|
68
|
+
"enduser may NOT set a co-attendee's attendeeStatuses entry",
|
|
69
|
+
() => attackerSDK.api.calendar_events.updateOne(e3.id, {
|
|
70
|
+
attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
|
|
71
|
+
}, { replaceObjectFields: true }),
|
|
72
|
+
handleAnyError,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ---- REJECTED: set a co-attendee's cancelledGroupAttendees entry ----
|
|
76
|
+
const e4 = await createGroupEvent([attacker.id, victim.id])
|
|
77
|
+
await async_test(
|
|
78
|
+
"enduser may NOT set a co-attendee's cancelledGroupAttendees entry",
|
|
79
|
+
() => attackerSDK.api.calendar_events.updateOne(e4.id, {
|
|
80
|
+
cancelledGroupAttendees: [{ id: victim.id, at: new Date() }],
|
|
81
|
+
}, { replaceObjectFields: true }),
|
|
82
|
+
handleAnyError,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// ---- REJECTED: drop a co-attendee's existing entry (would suppress their completion trigger) ----
|
|
86
|
+
const e5 = await createGroupEvent([attacker.id, victim.id])
|
|
87
|
+
await sdk.api.calendar_events.updateOne(e5.id, {
|
|
88
|
+
attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'Completed' }],
|
|
89
|
+
}, { replaceObjectFields: true })
|
|
90
|
+
await async_test(
|
|
91
|
+
"enduser may NOT remove a co-attendee's existing attendeeStatuses entry",
|
|
92
|
+
() => attackerSDK.api.calendar_events.updateOne(e5.id, {
|
|
93
|
+
attendeeStatuses: [{ id: attacker.id, at: new Date(), status: 'Cancelled' }], // silently drops victim's entry
|
|
94
|
+
}, { replaceObjectFields: true }),
|
|
95
|
+
handleAnyError,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// ---- REJECTED: modify a co-attendee's existing entry ----
|
|
99
|
+
const e6 = await createGroupEvent([attacker.id, victim.id])
|
|
100
|
+
await sdk.api.calendar_events.updateOne(e6.id, {
|
|
101
|
+
attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'Completed' }],
|
|
102
|
+
}, { replaceObjectFields: true })
|
|
103
|
+
await async_test(
|
|
104
|
+
"enduser may NOT modify a co-attendee's existing attendeeStatuses entry",
|
|
105
|
+
() => attackerSDK.api.calendar_events.updateOne(e6.id, {
|
|
106
|
+
attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
|
|
107
|
+
}, { replaceObjectFields: true }),
|
|
108
|
+
handleAnyError,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// ---- ALLOWED: self-cancel while PRESERVING a co-attendee's existing entry (realistic group self-cancel) ----
|
|
112
|
+
const e7 = await createGroupEvent([attacker.id, victim.id])
|
|
113
|
+
await sdk.api.calendar_events.updateOne(e7.id, {
|
|
114
|
+
cancelledGroupAttendees: [{ id: victim.id, at: new Date() }],
|
|
115
|
+
}, { replaceObjectFields: true })
|
|
116
|
+
const e7ForAttacker = await attackerSDK.api.calendar_events.getOne(e7.id)
|
|
117
|
+
await async_test(
|
|
118
|
+
"enduser may self-cancel while preserving a co-attendee's existing entry",
|
|
119
|
+
() => attackerSDK.api.calendar_events.updateOne(e7.id, {
|
|
120
|
+
cancelledGroupAttendees: [...(e7ForAttacker.cancelledGroupAttendees || []), { id: attacker.id, at: new Date() }],
|
|
121
|
+
}, { replaceObjectFields: true }),
|
|
122
|
+
passOnAnyResult,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// ---- ALLOWED (baseline): staff may set any attendee's status (constraint is enduser-only) ----
|
|
126
|
+
const e8 = await createGroupEvent([attacker.id, victim.id])
|
|
127
|
+
await async_test(
|
|
128
|
+
"staff may set any attendee's status",
|
|
129
|
+
() => sdk.api.calendar_events.updateOne(e8.id, {
|
|
130
|
+
attendeeStatuses: [{ id: victim.id, at: new Date(), status: 'No-showed' }],
|
|
131
|
+
}, { replaceObjectFields: true }),
|
|
132
|
+
passOnAnyResult,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
} finally {
|
|
136
|
+
for (const id of createdEventIds) { try { await sdk.api.calendar_events.deleteOne(id) } catch (_) {} }
|
|
137
|
+
for (const id of createdEnduserIds) { try { await sdk.api.endusers.deleteOne(id) } catch (_) {} }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Allow running this test file independently
|
|
142
|
+
if (require.main === module) {
|
|
143
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
144
|
+
const sdk = new Session({ host })
|
|
145
|
+
const sdkNonAdmin = new Session({ host })
|
|
146
|
+
|
|
147
|
+
const runTests = async () => {
|
|
148
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
149
|
+
await group_event_attendee_self_update_tests({ sdk, sdkNonAdmin })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
runTests()
|
|
153
|
+
.then(() => {
|
|
154
|
+
console.log("✅ Group event attendee self-update constraint tests completed successfully")
|
|
155
|
+
process.exit(0)
|
|
156
|
+
})
|
|
157
|
+
.catch((error) => {
|
|
158
|
+
console.error("❌ Group event attendee self-update constraint tests failed:", error)
|
|
159
|
+
process.exit(1)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
@@ -54,6 +54,11 @@ export const group_event_attendee_status_triggers_tests = async ({ sdk } : { sdk
|
|
|
54
54
|
}, { replaceObjectFields: true })
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
// Replicates the webapp "Mark Completed" action on the whole event (Calendar.tsx)
|
|
58
|
+
const markEventCompleted = async (eventId: string) => (
|
|
59
|
+
sdk.api.calendar_events.updateOne(eventId, { completedAt: new Date(), completedBy: sdk.userInfo.id })
|
|
60
|
+
)
|
|
61
|
+
|
|
57
62
|
const tagsOf = async (enduserId: string) => (await sdk.api.endusers.getOne(enduserId)).tags || []
|
|
58
63
|
|
|
59
64
|
try {
|
|
@@ -345,6 +350,136 @@ export const group_event_attendee_status_triggers_tests = async ({ sdk } : { sdk
|
|
|
345
350
|
{ onResult: (tags: string[]) => tags.length === 1 && tags.includes(TAG['No-showed']) }
|
|
346
351
|
)
|
|
347
352
|
|
|
353
|
+
// ============================================================
|
|
354
|
+
// Marking the WHOLE event Complete must not fan "Appointment Completed" out to attendees who
|
|
355
|
+
// already have an explicit per-attendee status — they were resolved individually (and already
|
|
356
|
+
// fired their own trigger via handle_attendee_status_change_triggers). Attendees with no status,
|
|
357
|
+
// or 'Confirmed', are still active participants and must still fire.
|
|
358
|
+
//
|
|
359
|
+
// Each case uses fresh endusers and a fresh event: triggers are throttled for 60s per
|
|
360
|
+
// trigger-per-enduser, and the completion handler early-returns once completedAt is already set.
|
|
361
|
+
//
|
|
362
|
+
// Not covered here: per-attendee 'Completed' followed by event-wide completion. That duplicate
|
|
363
|
+
// fire is already swallowed by the 60s throttle (which is applied before the action, and Add Tags
|
|
364
|
+
// is idempotent), so the assertion would pass with or without the filter.
|
|
365
|
+
// ============================================================
|
|
366
|
+
log_header("Event-wide completion respects per-attendee statuses")
|
|
367
|
+
|
|
368
|
+
// status -> tag that must NOT appear on the target after the event is completed
|
|
369
|
+
for (const status of ['No-showed', 'Cancelled', 'Rescheduled'] as const) {
|
|
370
|
+
const target = await createEnduser()
|
|
371
|
+
const active = await createEnduser()
|
|
372
|
+
const event = await createGroupEvent([target.id, active.id])
|
|
373
|
+
|
|
374
|
+
await setAttendeeStatuses(event.id, [{ id: target.id, status }])
|
|
375
|
+
await wait(undefined, 750)
|
|
376
|
+
|
|
377
|
+
// pre-condition: the per-attendee status fired its own trigger
|
|
378
|
+
await async_test(
|
|
379
|
+
`event-wide completion setup: '${status}' attendee has the '${status}' tag`,
|
|
380
|
+
() => tagsOf(target.id),
|
|
381
|
+
{ onResult: (tags: string[]) => tags.includes(TAG[status]) }
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
await markEventCompleted(event.id)
|
|
385
|
+
await wait(undefined, 1000)
|
|
386
|
+
|
|
387
|
+
await async_test(
|
|
388
|
+
`event-wide completion does NOT fire Completed for the '${status}' attendee`,
|
|
389
|
+
() => tagsOf(target.id),
|
|
390
|
+
{ onResult: (tags: string[]) => !tags.includes(TAG['Completed']) }
|
|
391
|
+
)
|
|
392
|
+
await async_test(
|
|
393
|
+
`event-wide completion leaves the '${status}' attendee with only its own tag`,
|
|
394
|
+
() => tagsOf(target.id),
|
|
395
|
+
{ onResult: (tags: string[]) => tags.length === 1 && tags.includes(TAG[status]) }
|
|
396
|
+
)
|
|
397
|
+
// guard against over-filtering: the un-statused attendee must still be completed
|
|
398
|
+
await async_test(
|
|
399
|
+
`event-wide completion still fires Completed for the active attendee (alongside '${status}')`,
|
|
400
|
+
() => tagsOf(active.id),
|
|
401
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Completed']) }
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// cancelledGroupAttendees with no attendeeStatuses entry — the shape written by the patient
|
|
406
|
+
// portal when an enduser cancels themselves out of a group event.
|
|
407
|
+
log_header("Event-wide completion respects legacy cancelledGroupAttendees")
|
|
408
|
+
|
|
409
|
+
const portalCancelled = await createEnduser()
|
|
410
|
+
const portalActive = await createEnduser()
|
|
411
|
+
const portalEvent = await createGroupEvent([portalCancelled.id, portalActive.id])
|
|
412
|
+
|
|
413
|
+
await sdk.api.calendar_events.updateOne(portalEvent.id, {
|
|
414
|
+
cancelledGroupAttendees: [{ id: portalCancelled.id, at: new Date() }],
|
|
415
|
+
}, { replaceObjectFields: true })
|
|
416
|
+
await wait(undefined, 750)
|
|
417
|
+
|
|
418
|
+
// pre-condition: the legacy branch fires Cancelled for them
|
|
419
|
+
await async_test(
|
|
420
|
+
"legacy cancelledGroupAttendees setup: attendee has the Cancelled tag",
|
|
421
|
+
() => tagsOf(portalCancelled.id),
|
|
422
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Cancelled']) }
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
await markEventCompleted(portalEvent.id)
|
|
426
|
+
await wait(undefined, 1000)
|
|
427
|
+
|
|
428
|
+
await async_test(
|
|
429
|
+
"event-wide completion does NOT fire Completed for a cancelledGroupAttendees attendee",
|
|
430
|
+
() => tagsOf(portalCancelled.id),
|
|
431
|
+
{ onResult: (tags: string[]) => !tags.includes(TAG['Completed']) }
|
|
432
|
+
)
|
|
433
|
+
await async_test(
|
|
434
|
+
"event-wide completion still fires Completed for the active attendee (legacy cancel case)",
|
|
435
|
+
() => tagsOf(portalActive.id),
|
|
436
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Completed']) }
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// ============================================================
|
|
440
|
+
// Over-filtering guards: 'Confirmed' and no-status attendees must still complete.
|
|
441
|
+
// ============================================================
|
|
442
|
+
log_header("Event-wide completion still fires for Confirmed / un-statused attendees")
|
|
443
|
+
|
|
444
|
+
const confirmed = await createEnduser()
|
|
445
|
+
const confirmedBystander = await createEnduser()
|
|
446
|
+
const confirmedEvent = await createGroupEvent([confirmed.id, confirmedBystander.id])
|
|
447
|
+
|
|
448
|
+
await setAttendeeStatuses(confirmedEvent.id, [{ id: confirmed.id, status: 'Confirmed' }])
|
|
449
|
+
await wait(undefined, 750)
|
|
450
|
+
await markEventCompleted(confirmedEvent.id)
|
|
451
|
+
await wait(undefined, 1000)
|
|
452
|
+
|
|
453
|
+
await async_test(
|
|
454
|
+
"event-wide completion fires Completed for a 'Confirmed' attendee",
|
|
455
|
+
() => tagsOf(confirmed.id),
|
|
456
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Completed']) }
|
|
457
|
+
)
|
|
458
|
+
await async_test(
|
|
459
|
+
"event-wide completion fires Completed for the bystander alongside 'Confirmed'",
|
|
460
|
+
() => tagsOf(confirmedBystander.id),
|
|
461
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Completed']) }
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
// baseline: a group event with no per-attendee statuses completes for everyone
|
|
465
|
+
const plain1 = await createEnduser()
|
|
466
|
+
const plain2 = await createEnduser()
|
|
467
|
+
const plain3 = await createEnduser()
|
|
468
|
+
const plainEvent = await createGroupEvent([plain1.id, plain2.id, plain3.id])
|
|
469
|
+
|
|
470
|
+
await markEventCompleted(plainEvent.id)
|
|
471
|
+
await wait(undefined, 1000)
|
|
472
|
+
|
|
473
|
+
const plainAttendees = [plain1, plain2, plain3]
|
|
474
|
+
for (let i = 0; i < plainAttendees.length; i++) {
|
|
475
|
+
const enduserId = plainAttendees[i].id
|
|
476
|
+
await async_test(
|
|
477
|
+
`event-wide completion fires Completed for un-statused attendee #${i + 1}`,
|
|
478
|
+
() => tagsOf(enduserId),
|
|
479
|
+
{ onResult: (tags: string[]) => tags.includes(TAG['Completed']) }
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
348
483
|
} finally {
|
|
349
484
|
for (const id of createdEventIds) {
|
|
350
485
|
try { await sdk.api.calendar_events.deleteOne(id) } catch (_) {}
|