@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.
Files changed (128) hide show
  1. package/lib/cjs/enduser.d.ts +1 -1
  2. package/lib/cjs/enduser.d.ts.map +1 -1
  3. package/lib/cjs/enduser.js +1 -0
  4. package/lib/cjs/enduser.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +505 -0
  8. package/lib/cjs/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/configurations_public_read.test.js +173 -0
  12. package/lib/cjs/tests/api_tests/configurations_public_read.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
  14. package/lib/cjs/tests/api_tests/form_started_trigger.test.js +87 -24
  15. package/lib/cjs/tests/api_tests/form_started_trigger.test.js.map +1 -1
  16. package/lib/cjs/tests/api_tests/form_translations.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/form_translations.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/form_translations.test.js +259 -0
  19. package/lib/cjs/tests/api_tests/form_translations.test.js.map +1 -0
  20. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
  21. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js +293 -0
  23. package/lib/cjs/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
  25. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
  26. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
  27. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
  28. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
  29. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js +277 -0
  30. package/lib/cjs/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
  31. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
  32. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js +262 -78
  33. package/lib/cjs/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
  34. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
  35. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
  36. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +315 -0
  37. package/lib/cjs/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
  38. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
  39. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
  40. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
  41. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
  42. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +343 -80
  43. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
  44. package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
  45. package/lib/cjs/tests/api_tests/welle_partner_token.test.js +74 -35
  46. package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -1
  47. package/lib/cjs/tests/stripe_webhook_helpers.d.ts +134 -0
  48. package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -1
  49. package/lib/cjs/tests/stripe_webhook_helpers.js +126 -1
  50. package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -1
  51. package/lib/cjs/tests/tests.d.ts.map +1 -1
  52. package/lib/cjs/tests/tests.js +242 -212
  53. package/lib/cjs/tests/tests.js.map +1 -1
  54. package/lib/esm/enduser.d.ts +2 -1
  55. package/lib/esm/enduser.d.ts.map +1 -1
  56. package/lib/esm/enduser.js +1 -0
  57. package/lib/esm/enduser.js.map +1 -1
  58. package/lib/esm/sdk.d.ts +3 -2
  59. package/lib/esm/sdk.d.ts.map +1 -1
  60. package/lib/esm/session.d.ts +1 -0
  61. package/lib/esm/session.d.ts.map +1 -1
  62. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts +6 -0
  63. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.d.ts.map +1 -0
  64. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js +501 -0
  65. package/lib/esm/tests/api_tests/calendar_event_create_and_bulk_triggers.test.js.map +1 -0
  66. package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts +6 -0
  67. package/lib/esm/tests/api_tests/configurations_public_read.test.d.ts.map +1 -0
  68. package/lib/esm/tests/api_tests/configurations_public_read.test.js +169 -0
  69. package/lib/esm/tests/api_tests/configurations_public_read.test.js.map +1 -0
  70. package/lib/esm/tests/api_tests/form_started_trigger.test.d.ts.map +1 -1
  71. package/lib/esm/tests/api_tests/form_started_trigger.test.js +87 -24
  72. package/lib/esm/tests/api_tests/form_started_trigger.test.js.map +1 -1
  73. package/lib/esm/tests/api_tests/form_translations.test.d.ts +6 -0
  74. package/lib/esm/tests/api_tests/form_translations.test.d.ts.map +1 -0
  75. package/lib/esm/tests/api_tests/form_translations.test.js +255 -0
  76. package/lib/esm/tests/api_tests/form_translations.test.js.map +1 -0
  77. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts +6 -0
  78. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.d.ts.map +1 -0
  79. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js +289 -0
  80. package/lib/esm/tests/api_tests/group_event_attendee_self_update.test.js.map +1 -0
  81. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -1
  82. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +340 -124
  83. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -1
  84. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts +6 -0
  85. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js +273 -0
  87. package/lib/esm/tests/api_tests/mdi_trigger_refill.test.js.map +1 -0
  88. package/lib/esm/tests/api_tests/purchase_made_trigger.test.d.ts.map +1 -1
  89. package/lib/esm/tests/api_tests/purchase_made_trigger.test.js +262 -78
  90. package/lib/esm/tests/api_tests/purchase_made_trigger.test.js.map +1 -1
  91. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts +31 -0
  92. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.d.ts.map +1 -0
  93. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js +311 -0
  94. package/lib/esm/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.js.map +1 -0
  95. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -1
  96. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +206 -198
  97. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -1
  98. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -1
  99. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +344 -81
  100. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -1
  101. package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -1
  102. package/lib/esm/tests/api_tests/welle_partner_token.test.js +74 -35
  103. package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -1
  104. package/lib/esm/tests/stripe_webhook_helpers.d.ts +134 -0
  105. package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -1
  106. package/lib/esm/tests/stripe_webhook_helpers.js +119 -0
  107. package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -1
  108. package/lib/esm/tests/tests.d.ts.map +1 -1
  109. package/lib/esm/tests/tests.js +242 -212
  110. package/lib/esm/tests/tests.js.map +1 -1
  111. package/lib/tsconfig.tsbuildinfo +1 -1
  112. package/package.json +10 -10
  113. package/src/enduser.ts +4 -2
  114. package/src/tests/api_tests/calendar_event_create_and_bulk_triggers.test.ts +293 -0
  115. package/src/tests/api_tests/configurations_public_read.test.ts +116 -0
  116. package/src/tests/api_tests/form_started_trigger.test.ts +49 -1
  117. package/src/tests/api_tests/form_translations.test.ts +205 -0
  118. package/src/tests/api_tests/group_event_attendee_self_update.test.ts +161 -0
  119. package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +135 -0
  120. package/src/tests/api_tests/mdi_trigger_refill.test.ts +184 -0
  121. package/src/tests/api_tests/purchase_made_trigger.test.ts +137 -0
  122. package/src/tests/api_tests/security/F-0006-develop-health-benefit-verification-rbac.test.ts +204 -0
  123. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +16 -6
  124. package/src/tests/api_tests/stripe_webhook_triggers.test.ts +221 -0
  125. package/src/tests/api_tests/welle_partner_token.test.ts +49 -6
  126. package/src/tests/stripe_webhook_helpers.ts +116 -0
  127. package/src/tests/tests.ts +29 -12
  128. 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 (_) {}