@tellescope/sdk 1.255.0 → 1.255.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.js +236 -0
  4. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.js +108 -73
  7. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts +6 -0
  9. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -0
  10. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +587 -0
  11. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -0
  12. package/lib/cjs/tests/api_tests/healthie_force_sync.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/healthie_force_sync.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/healthie_force_sync.test.js +165 -0
  15. package/lib/cjs/tests/api_tests/healthie_force_sync.test.js.map +1 -0
  16. package/lib/cjs/tests/tests.d.ts.map +1 -1
  17. package/lib/cjs/tests/tests.js +55 -41
  18. package/lib/cjs/tests/tests.js.map +1 -1
  19. package/lib/esm/sdk.d.ts +2 -2
  20. package/lib/esm/session.d.ts +0 -1
  21. package/lib/esm/session.d.ts.map +1 -1
  22. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.d.ts +6 -0
  23. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.d.ts.map +1 -0
  24. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.js +232 -0
  25. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.js.map +1 -0
  26. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.d.ts.map +1 -1
  27. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.js +109 -74
  28. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.js.map +1 -1
  29. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts +6 -0
  30. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -0
  31. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +583 -0
  32. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -0
  33. package/lib/esm/tests/api_tests/healthie_force_sync.test.d.ts +6 -0
  34. package/lib/esm/tests/api_tests/healthie_force_sync.test.d.ts.map +1 -0
  35. package/lib/esm/tests/api_tests/healthie_force_sync.test.js +161 -0
  36. package/lib/esm/tests/api_tests/healthie_force_sync.test.js.map +1 -0
  37. package/lib/esm/tests/tests.d.ts.map +1 -1
  38. package/lib/esm/tests/tests.js +55 -41
  39. package/lib/esm/tests/tests.js.map +1 -1
  40. package/lib/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +10 -10
  42. package/src/tests/api_tests/appointment_no_showed_trigger.test.ts +144 -0
  43. package/src/tests/api_tests/calendar_events_bulk_update.test.ts +22 -0
  44. package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +381 -0
  45. package/src/tests/tests.ts +7 -1
  46. package/test_generated.pdf +0 -0
@@ -0,0 +1,144 @@
1
+ import { Session } from "../../sdk"
2
+ import { log_header, wait, async_test } from "@tellescope/testing"
3
+ import { Enduser } from "@tellescope/types-client"
4
+ import { setup_tests } from "../setup"
5
+
6
+ const host = process.env.API_URL || "http://localhost:8080"
7
+
8
+ // Covers the event-wide "Appointment No-Showed" trigger path (handle_no_showed_event), which had
9
+ // no dedicated test. Mirrors appointment_completed_trigger.test.ts.
10
+ export const appointment_no_showed_trigger_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
11
+ log_header("Automation Trigger Tests (Appointment No-Showed)")
12
+
13
+ const t1 = await sdk.api.automation_triggers.createOne({
14
+ event: { type: 'Appointment No-Showed', info: {} },
15
+ action: { type: 'Add Tags', info: { tags: ['No-Show - Any'] } },
16
+ status: 'Active',
17
+ title: "No-Show - Any",
18
+ })
19
+ const t2 = await sdk.api.automation_triggers.createOne({
20
+ event: { type: 'Appointment No-Showed', info: { titles: ['Test Appointment'] } },
21
+ action: { type: 'Add Tags', info: { tags: ['No-Show - By Title'] } },
22
+ status: 'Active',
23
+ title: "No-Show - By Title",
24
+ })
25
+ const template = await sdk.api.calendar_event_templates.createOne({
26
+ title: 'No-Show Template',
27
+ durationInMinutes: 30,
28
+ })
29
+ const t3 = await sdk.api.automation_triggers.createOne({
30
+ event: { type: 'Appointment No-Showed', info: { templateIds: [template.id] } },
31
+ action: { type: 'Add Tags', info: { tags: ['No-Show - By Template'] } },
32
+ status: 'Active',
33
+ title: "No-Show - By Template",
34
+ })
35
+
36
+ // Separate endusers to avoid per-trigger throttling (1 min per trigger per enduser)
37
+ const enduser1 = await sdk.api.endusers.createOne({})
38
+ const enduser2 = await sdk.api.endusers.createOne({})
39
+ const enduser3 = await sdk.api.endusers.createOne({})
40
+
41
+ // Note: the "Appointment No-Showed" trigger fires only on UPDATE (handle_no_showed_event);
42
+ // there is no create-time no-show path, so every case creates a normal event then updates it.
43
+
44
+ // Test 1: Update to no-showed by title (should trigger Any + By Title)
45
+ const event1 = await sdk.api.calendar_events.createOne({
46
+ title: 'Test Appointment',
47
+ durationInMinutes: 30,
48
+ startTimeInMS: Date.now(),
49
+ attendees: [{ type: 'enduser', id: enduser1.id }],
50
+ })
51
+ await wait(undefined, 500)
52
+ await async_test(
53
+ "No initial triggers before no-show",
54
+ () => sdk.api.endusers.getOne(enduser1.id),
55
+ { onResult: (e: Enduser) => !e.tags?.length }
56
+ )
57
+ await sdk.api.calendar_events.updateOne(event1.id, { noShowedAt: new Date() })
58
+ await wait(undefined, 500)
59
+ await async_test(
60
+ "Update to no-showed triggers automation (Any + By Title)",
61
+ () => sdk.api.endusers.getOne(enduser1.id),
62
+ { onResult: (e: Enduser) =>
63
+ e.tags?.length === 2
64
+ && e.tags.includes('No-Show - Any')
65
+ && e.tags.includes('No-Show - By Title')
66
+ }
67
+ )
68
+
69
+ // Test 2: Template-based no-show (should trigger Any + By Template)
70
+ const event2 = await sdk.api.calendar_events.createOne({
71
+ title: 'Template Test',
72
+ durationInMinutes: 30,
73
+ startTimeInMS: Date.now(),
74
+ attendees: [{ type: 'enduser', id: enduser2.id }],
75
+ templateId: template.id,
76
+ })
77
+ await wait(undefined, 500)
78
+ await sdk.api.calendar_events.updateOne(event2.id, { noShowedAt: new Date() })
79
+ await wait(undefined, 500)
80
+ await async_test(
81
+ "Template-based no-showed appointment triggers automation (Any + By Template)",
82
+ () => sdk.api.endusers.getOne(enduser2.id),
83
+ { onResult: (e: Enduser) =>
84
+ e.tags?.length === 2
85
+ && e.tags.includes('No-Show - Any')
86
+ && e.tags.includes('No-Show - By Template')
87
+ }
88
+ )
89
+
90
+ // Test 3: Non-matching title only fires the "Any" trigger
91
+ const event3 = await sdk.api.calendar_events.createOne({
92
+ title: 'Other Title',
93
+ durationInMinutes: 30,
94
+ startTimeInMS: Date.now(),
95
+ attendees: [{ type: 'enduser', id: enduser3.id }],
96
+ })
97
+ await wait(undefined, 500)
98
+ await sdk.api.calendar_events.updateOne(event3.id, { noShowedAt: new Date() })
99
+ await wait(undefined, 500)
100
+ await async_test(
101
+ "No-show with non-matching title fires only the Any trigger",
102
+ () => sdk.api.endusers.getOne(enduser3.id),
103
+ { onResult: (e: Enduser) =>
104
+ e.tags?.length === 1
105
+ && e.tags.includes('No-Show - Any')
106
+ }
107
+ )
108
+
109
+ // Cleanup
110
+ await Promise.all([
111
+ sdk.api.automation_triggers.deleteOne(t1.id),
112
+ sdk.api.automation_triggers.deleteOne(t2.id),
113
+ sdk.api.automation_triggers.deleteOne(t3.id),
114
+ sdk.api.calendar_event_templates.deleteOne(template.id),
115
+ sdk.api.endusers.deleteOne(enduser1.id),
116
+ sdk.api.endusers.deleteOne(enduser2.id),
117
+ sdk.api.endusers.deleteOne(enduser3.id),
118
+ sdk.api.calendar_events.deleteOne(event1.id),
119
+ sdk.api.calendar_events.deleteOne(event2.id),
120
+ sdk.api.calendar_events.deleteOne(event3.id),
121
+ ])
122
+ }
123
+
124
+ // Allow running this test file independently
125
+ if (require.main === module) {
126
+ console.log(`🌐 Using API URL: ${host}`)
127
+ const sdk = new Session({ host })
128
+ const sdkNonAdmin = new Session({ host })
129
+
130
+ const runTests = async () => {
131
+ await setup_tests(sdk, sdkNonAdmin)
132
+ await appointment_no_showed_trigger_tests({ sdk, sdkNonAdmin })
133
+ }
134
+
135
+ runTests()
136
+ .then(() => {
137
+ console.log("✅ Appointment No-Showed trigger tests completed successfully")
138
+ process.exit(0)
139
+ })
140
+ .catch((error) => {
141
+ console.error("❌ Appointment No-Showed trigger tests failed:", error)
142
+ process.exit(1)
143
+ })
144
+ }
@@ -6,6 +6,7 @@ import {
6
6
  assert,
7
7
  handleAnyError,
8
8
  log_header,
9
+ wait,
9
10
  } from "@tellescope/testing"
10
11
  import { setup_tests } from "../setup"
11
12
 
@@ -17,6 +18,7 @@ export const calendar_events_bulk_update_tests = async ({ sdk }: { sdk: Session
17
18
  // Track all created resources for cleanup
18
19
  const createdEventIds: string[] = []
19
20
  const createdEnduserIds: string[] = []
21
+ const createdTriggerIds: string[] = []
20
22
 
21
23
  const createEvent = async (overrides: Record<string, any> = {}) => {
22
24
  const event = await sdk.api.calendar_events.createOne({
@@ -305,6 +307,16 @@ export const calendar_events_bulk_update_tests = async ({ sdk }: { sdk: Session
305
307
  // --- Cancel for attendee ---
306
308
  log_header("Bulk Update - Recurring Cancel for Attendee")
307
309
 
310
+ // Cancelled trigger to verify cancel_for_attendee fires the Appointment Cancelled trigger
311
+ // (previously this path's mutation was tested, but the trigger firing was not)
312
+ const cancelledTrigger = await sdk.api.automation_triggers.createOne({
313
+ event: { type: 'Appointment Cancelled', info: {} },
314
+ action: { type: 'Add Tags', info: { tags: ['Bulk Cancel For Attendee'] } },
315
+ status: 'Active',
316
+ title: "Bulk Cancel For Attendee",
317
+ })
318
+ createdTriggerIds.push(cancelledTrigger.id)
319
+
308
320
  await async_test(
309
321
  "cancel_for_attendee adds enduser to cancelledGroupAttendees across series",
310
322
  () => sdk.api.calendar_events.bulk_update({
@@ -323,6 +335,13 @@ export const calendar_events_bulk_update_tests = async ({ sdk }: { sdk: Session
323
335
  }
324
336
  )
325
337
 
338
+ await wait(undefined, 750) // allow Cancelled trigger to fire
339
+ await async_test(
340
+ "cancel_for_attendee fires Appointment Cancelled trigger for the attendee",
341
+ () => sdk.api.endusers.getOne(enduser.id),
342
+ { onResult: (e: any) => !!e.tags?.includes('Bulk Cancel For Attendee') }
343
+ )
344
+
326
345
  // --- Uncancel for attendee ---
327
346
  log_header("Bulk Update - Recurring Uncancel for Attendee")
328
347
 
@@ -392,6 +411,9 @@ export const calendar_events_bulk_update_tests = async ({ sdk }: { sdk: Session
392
411
  for (const id of createdEnduserIds) {
393
412
  try { await sdk.api.endusers.deleteOne(id) } catch (_) {}
394
413
  }
414
+ for (const id of createdTriggerIds) {
415
+ try { await sdk.api.automation_triggers.deleteOne(id) } catch (_) {}
416
+ }
395
417
  }
396
418
  }
397
419
 
@@ -0,0 +1,381 @@
1
+ import { Session } from "../../sdk"
2
+ import { log_header, wait, async_test } from "@tellescope/testing"
3
+ import { Enduser } from "@tellescope/types-client"
4
+ import { AttendeeStatus } from "@tellescope/types-models"
5
+ import { setup_tests } from "../setup"
6
+
7
+ const host = process.env.API_URL || "http://localhost:8080"
8
+
9
+ // Tags applied by the four per-status triggers. Distinct so we can prove exactly which trigger
10
+ // fired against which attendee (no false positives / no false negatives).
11
+ const TAG = {
12
+ 'Completed': 'Group Attendee: Completed',
13
+ 'No-showed': 'Group Attendee: No-showed',
14
+ 'Rescheduled': 'Group Attendee: Rescheduled',
15
+ 'Cancelled': 'Group Attendee: Cancelled',
16
+ } as const
17
+
18
+ type TrackableStatus = keyof typeof TAG
19
+ const ALL_STATUSES: TrackableStatus[] = ['Completed', 'No-showed', 'Rescheduled', 'Cancelled']
20
+
21
+ export const group_event_attendee_status_triggers_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
22
+ log_header("Automation Trigger Tests (Group Event Per-Attendee Status)")
23
+
24
+ const createdTriggerIds: string[] = []
25
+ const createdEnduserIds: string[] = []
26
+ const createdEventIds: string[] = []
27
+
28
+ const createEnduser = async () => {
29
+ const e = await sdk.api.endusers.createOne({})
30
+ createdEnduserIds.push(e.id)
31
+ return e
32
+ }
33
+ const createGroupEvent = async (attendeeIds: string[]) => {
34
+ const event = await sdk.api.calendar_events.createOne({
35
+ title: 'Group Status Test',
36
+ durationInMinutes: 30,
37
+ startTimeInMS: Date.now() + Math.floor(Math.random() * 100000),
38
+ attendees: attendeeIds.map(id => ({ type: 'enduser' as const, id })),
39
+ })
40
+ createdEventIds.push(event.id)
41
+ return event
42
+ }
43
+
44
+ // Replicates the webapp AttendeeStatusEditor save: writes attendeeStatuses, and mirrors statuses
45
+ // into cancelledGroupAttendees for Cancelled/No-showed/Rescheduled (to suppress reminders).
46
+ const setAttendeeStatuses = async (eventId: string, statuses: { id: string, status: AttendeeStatus['status'] }[]) => (
47
+ sdk.api.calendar_events.updateOne(eventId, {
48
+ attendeeStatuses: statuses.map(s => ({ id: s.id, at: new Date(), status: s.status })),
49
+ cancelledGroupAttendees: (
50
+ statuses
51
+ .filter(s => s.status === 'Cancelled' || s.status === 'No-showed' || s.status === 'Rescheduled')
52
+ .map(s => ({ id: s.id, at: new Date() }))
53
+ ),
54
+ }, { replaceObjectFields: true })
55
+ )
56
+
57
+ const tagsOf = async (enduserId: string) => (await sdk.api.endusers.getOne(enduserId)).tags || []
58
+
59
+ try {
60
+ // One "Add Tags" trigger per status, matching any appointment (info: {})
61
+ for (const status of ALL_STATUSES) {
62
+ const t = await sdk.api.automation_triggers.createOne({
63
+ event: {
64
+ type: status === 'No-showed' ? 'Appointment No-Showed'
65
+ : status === 'Completed' ? 'Appointment Completed'
66
+ : status === 'Rescheduled' ? 'Appointment Rescheduled'
67
+ : 'Appointment Cancelled',
68
+ info: {},
69
+ },
70
+ action: { type: 'Add Tags', info: { tags: [TAG[status]] } },
71
+ status: 'Active',
72
+ title: `Group Attendee ${status}`,
73
+ })
74
+ createdTriggerIds.push(t.id)
75
+ }
76
+
77
+ // ============================================================
78
+ // For each status: target gets exactly its tag; bystanders get nothing.
79
+ // ============================================================
80
+ for (const status of ALL_STATUSES) {
81
+ log_header(`Per-attendee '${status}' in a group event`)
82
+
83
+ const target = await createEnduser()
84
+ const bystander1 = await createEnduser()
85
+ const bystander2 = await createEnduser()
86
+ const event = await createGroupEvent([target.id, bystander1.id, bystander2.id])
87
+
88
+ await setAttendeeStatuses(event.id, [{ id: target.id, status }])
89
+ await wait(undefined, 750) // allow trigger to fire
90
+
91
+ // 1 + 2. True positive / no false negative: target has the correct tag
92
+ await async_test(
93
+ `'${status}' fires its trigger for the target attendee`,
94
+ () => tagsOf(target.id),
95
+ { onResult: (tags: string[]) => tags.includes(TAG[status]) }
96
+ )
97
+
98
+ // 3. No false positive (wrong trigger): target has none of the other status tags
99
+ await async_test(
100
+ `'${status}' does NOT fire any other status trigger for the target`,
101
+ () => tagsOf(target.id),
102
+ { onResult: (tags: string[]) => ALL_STATUSES.every(s => s === status || !tags.includes(TAG[s])) }
103
+ )
104
+
105
+ // 4. No false positive (collateral): bystanders received zero tags
106
+ await async_test(
107
+ `'${status}' does NOT fire for bystander attendee #1`,
108
+ () => sdk.api.endusers.getOne(bystander1.id),
109
+ { onResult: (e: Enduser) => !e.tags?.length }
110
+ )
111
+ await async_test(
112
+ `'${status}' does NOT fire for bystander attendee #2`,
113
+ () => sdk.api.endusers.getOne(bystander2.id),
114
+ { onResult: (e: Enduser) => !e.tags?.length }
115
+ )
116
+ }
117
+
118
+ // ============================================================
119
+ // Multiple distinct statuses set in one group event — no cross-talk.
120
+ // ============================================================
121
+ log_header("Multiple distinct per-attendee statuses in one group event")
122
+
123
+ const aCompleted = await createEnduser()
124
+ const bNoShow = await createEnduser()
125
+ const cCancelled = await createEnduser()
126
+ const dUnset = await createEnduser()
127
+ const mixedEvent = await createGroupEvent([aCompleted.id, bNoShow.id, cCancelled.id, dUnset.id])
128
+
129
+ await setAttendeeStatuses(mixedEvent.id, [
130
+ { id: aCompleted.id, status: 'Completed' },
131
+ { id: bNoShow.id, status: 'No-showed' },
132
+ { id: cCancelled.id, status: 'Cancelled' },
133
+ // dUnset intentionally left without a status
134
+ ])
135
+ await wait(undefined, 1000) // allow all triggers to fire
136
+
137
+ await async_test(
138
+ "mixed: Completed attendee gets only the Completed tag",
139
+ () => tagsOf(aCompleted.id),
140
+ { onResult: (tags: string[]) => tags.includes(TAG['Completed']) && ALL_STATUSES.every(s => s === 'Completed' || !tags.includes(TAG[s])) }
141
+ )
142
+ await async_test(
143
+ "mixed: No-showed attendee gets only the No-showed tag",
144
+ () => tagsOf(bNoShow.id),
145
+ { onResult: (tags: string[]) => tags.includes(TAG['No-showed']) && ALL_STATUSES.every(s => s === 'No-showed' || !tags.includes(TAG[s])) }
146
+ )
147
+ await async_test(
148
+ "mixed: Cancelled attendee gets only the Cancelled tag",
149
+ () => tagsOf(cCancelled.id),
150
+ { onResult: (tags: string[]) => tags.includes(TAG['Cancelled']) && ALL_STATUSES.every(s => s === 'Cancelled' || !tags.includes(TAG[s])) }
151
+ )
152
+ await async_test(
153
+ "mixed: unset attendee receives no tags",
154
+ () => sdk.api.endusers.getOne(dUnset.id),
155
+ { onResult: (e: Enduser) => !e.tags?.length }
156
+ )
157
+
158
+ // ============================================================
159
+ // Status transitions (A -> B): changing an attendee's status fires the NEW status's trigger.
160
+ // Exercises both directions of the cancelledGroupAttendees mirroring:
161
+ // - mirrored -> unmirrored (No-showed -> Completed)
162
+ // - unmirrored -> mirrored (Completed -> No-showed)
163
+ // The prior status's tag remains (it fired at its own transition); no spurious Cancelled fires
164
+ // from the cancelledGroupAttendees add/remove, and bystanders never fire.
165
+ // ============================================================
166
+ log_header("Per-attendee status transitions fire the new status only")
167
+
168
+ // mirrored -> unmirrored
169
+ const transA = await createEnduser()
170
+ const transABystander = await createEnduser()
171
+ const transAEvent = await createGroupEvent([transA.id, transABystander.id])
172
+
173
+ await setAttendeeStatuses(transAEvent.id, [{ id: transA.id, status: 'No-showed' }])
174
+ await wait(undefined, 750)
175
+ await async_test(
176
+ "transition step 1: No-showed fires No-showed",
177
+ () => tagsOf(transA.id),
178
+ { onResult: (tags: string[]) => tags.includes(TAG['No-showed']) }
179
+ )
180
+ await setAttendeeStatuses(transAEvent.id, [{ id: transA.id, status: 'Completed' }])
181
+ await wait(undefined, 750)
182
+ await async_test(
183
+ "transition No-showed -> Completed fires Completed (No-showed tag retained, no Cancelled)",
184
+ () => tagsOf(transA.id),
185
+ { onResult: (tags: string[]) =>
186
+ tags.includes(TAG['Completed'])
187
+ && tags.includes(TAG['No-showed'])
188
+ && !tags.includes(TAG['Cancelled'])
189
+ && !tags.includes(TAG['Rescheduled'])
190
+ }
191
+ )
192
+ await async_test(
193
+ "transition (mirrored->unmirrored): bystander never fires",
194
+ () => sdk.api.endusers.getOne(transABystander.id),
195
+ { onResult: (e: Enduser) => !e.tags?.length }
196
+ )
197
+
198
+ // unmirrored -> mirrored
199
+ const transB = await createEnduser()
200
+ const transBBystander = await createEnduser()
201
+ const transBEvent = await createGroupEvent([transB.id, transBBystander.id])
202
+
203
+ await setAttendeeStatuses(transBEvent.id, [{ id: transB.id, status: 'Completed' }])
204
+ await wait(undefined, 750)
205
+ await setAttendeeStatuses(transBEvent.id, [{ id: transB.id, status: 'No-showed' }])
206
+ await wait(undefined, 750)
207
+ await async_test(
208
+ "transition Completed -> No-showed fires No-showed (Completed tag retained, no Cancelled)",
209
+ () => tagsOf(transB.id),
210
+ { onResult: (tags: string[]) =>
211
+ tags.includes(TAG['No-showed'])
212
+ && tags.includes(TAG['Completed'])
213
+ && !tags.includes(TAG['Cancelled'])
214
+ && !tags.includes(TAG['Rescheduled'])
215
+ }
216
+ )
217
+ await async_test(
218
+ "transition (unmirrored->mirrored): bystander never fires",
219
+ () => sdk.api.endusers.getOne(transBBystander.id),
220
+ { onResult: (e: Enduser) => !e.tags?.length }
221
+ )
222
+
223
+ // ============================================================
224
+ // 'by' filter on the per-attendee Cancelled path. The AttendeeStatusEditor update does not set
225
+ // statusChangeSource, so handle_cancelled_event's by-filter (which requires statusChangeSource
226
+ // to match 'user'/'enduser') excludes by-filtered triggers. Only the unfiltered (by: undefined)
227
+ // Cancelled trigger fires for a per-attendee cancellation. This pins current behavior; see the
228
+ // note returned with this change about whether by:'user' SHOULD fire here.
229
+ // ============================================================
230
+ log_header("Per-attendee Cancelled respects 'by' filter")
231
+
232
+ const byUserTrigger = await sdk.api.automation_triggers.createOne({
233
+ event: { type: 'Appointment Cancelled', info: { by: 'user' } },
234
+ action: { type: 'Add Tags', info: { tags: ['Group Attendee Cancelled by:user'] } },
235
+ status: 'Active',
236
+ title: "Group Attendee Cancelled by user",
237
+ })
238
+ createdTriggerIds.push(byUserTrigger.id)
239
+ const byEnduserTrigger = await sdk.api.automation_triggers.createOne({
240
+ event: { type: 'Appointment Cancelled', info: { by: 'enduser' } },
241
+ action: { type: 'Add Tags', info: { tags: ['Group Attendee Cancelled by:enduser'] } },
242
+ status: 'Active',
243
+ title: "Group Attendee Cancelled by enduser",
244
+ })
245
+ createdTriggerIds.push(byEnduserTrigger.id)
246
+
247
+ const byTarget = await createEnduser()
248
+ const byBystander = await createEnduser()
249
+ const byEvent = await createGroupEvent([byTarget.id, byBystander.id])
250
+
251
+ await setAttendeeStatuses(byEvent.id, [{ id: byTarget.id, status: 'Cancelled' }])
252
+ await wait(undefined, 750)
253
+
254
+ await async_test(
255
+ "per-attendee cancel fires the unfiltered (by: undefined) Cancelled trigger",
256
+ () => tagsOf(byTarget.id),
257
+ { onResult: (tags: string[]) => tags.includes(TAG['Cancelled']) }
258
+ )
259
+ await async_test(
260
+ "per-attendee cancel does NOT fire by:'enduser' Cancelled trigger",
261
+ () => tagsOf(byTarget.id),
262
+ { onResult: (tags: string[]) => !tags.includes('Group Attendee Cancelled by:enduser') }
263
+ )
264
+ await async_test(
265
+ "per-attendee cancel does NOT fire by:'user' Cancelled trigger (statusChangeSource unset)",
266
+ () => tagsOf(byTarget.id),
267
+ { onResult: (tags: string[]) => !tags.includes('Group Attendee Cancelled by:user') }
268
+ )
269
+ await async_test(
270
+ "'by' filter case: bystander never fires",
271
+ () => sdk.api.endusers.getOne(byBystander.id),
272
+ { onResult: (e: Enduser) => !e.tags?.length }
273
+ )
274
+
275
+ // ============================================================
276
+ // bulk_update cancel_for_attendee fires Cancelled exactly once (via the new handler;
277
+ // the legacy cancelledGroupAttendees branch is suppressed by the attendeeStatuses filter).
278
+ // ============================================================
279
+ log_header("bulk_update cancel_for_attendee fires Cancelled for attendee")
280
+
281
+ const bulkTarget = await createEnduser()
282
+ const bulkBystander = await createEnduser()
283
+ const now = Date.now()
284
+ const DAY = 24 * 60 * 60 * 1000
285
+ const rootEvent = await sdk.api.calendar_events.createOne({
286
+ title: 'Group Status Test',
287
+ durationInMinutes: 30,
288
+ startTimeInMS: now,
289
+ attendees: [{ type: 'enduser', id: bulkTarget.id }, { type: 'enduser', id: bulkBystander.id }],
290
+ })
291
+ createdEventIds.push(rootEvent.id)
292
+ const childEvent = await sdk.api.calendar_events.createOne({
293
+ title: 'Group Status Test',
294
+ durationInMinutes: 30,
295
+ startTimeInMS: now + DAY,
296
+ copiedFrom: rootEvent.id,
297
+ attendees: [{ type: 'enduser', id: bulkTarget.id }, { type: 'enduser', id: bulkBystander.id }],
298
+ })
299
+ createdEventIds.push(childEvent.id)
300
+
301
+ await sdk.api.calendar_events.bulk_update({
302
+ recurringEventId: rootEvent.id,
303
+ action: 'cancel_for_attendee',
304
+ scope: 'all',
305
+ enduserId: bulkTarget.id,
306
+ })
307
+ await wait(undefined, 750)
308
+
309
+ await async_test(
310
+ "cancel_for_attendee fires Cancelled trigger for the target",
311
+ () => tagsOf(bulkTarget.id),
312
+ { onResult: (tags: string[]) => tags.includes(TAG['Cancelled']) }
313
+ )
314
+ await async_test(
315
+ "cancel_for_attendee does NOT fire for the bystander",
316
+ () => sdk.api.endusers.getOne(bulkBystander.id),
317
+ { onResult: (e: Enduser) => !e.tags?.length }
318
+ )
319
+
320
+ // ============================================================
321
+ // 'Confirmed' and clearing a status fire nothing.
322
+ // ============================================================
323
+ log_header("Confirmed / cleared statuses fire no triggers")
324
+
325
+ const confirmEnduser = await createEnduser()
326
+ const clearEnduser = await createEnduser()
327
+ const noopEvent = await createGroupEvent([confirmEnduser.id, clearEnduser.id])
328
+
329
+ // set clearEnduser to No-showed, then clear it; set confirmEnduser to Confirmed
330
+ await setAttendeeStatuses(noopEvent.id, [{ id: clearEnduser.id, status: 'No-showed' }])
331
+ await wait(undefined, 750)
332
+ await setAttendeeStatuses(noopEvent.id, [{ id: confirmEnduser.id, status: 'Confirmed' }]) // also clears clearEnduser
333
+ await wait(undefined, 750)
334
+
335
+ await async_test(
336
+ "'Confirmed' status fires no trigger",
337
+ () => sdk.api.endusers.getOne(confirmEnduser.id),
338
+ { onResult: (e: Enduser) => !e.tags?.length }
339
+ )
340
+ // clearEnduser previously got the No-showed tag (that firing is expected); clearing must not
341
+ // add anything new. Assert it still has exactly the one No-showed tag and nothing else.
342
+ await async_test(
343
+ "clearing a status fires no additional trigger",
344
+ () => tagsOf(clearEnduser.id),
345
+ { onResult: (tags: string[]) => tags.length === 1 && tags.includes(TAG['No-showed']) }
346
+ )
347
+
348
+ } finally {
349
+ for (const id of createdEventIds) {
350
+ try { await sdk.api.calendar_events.deleteOne(id) } catch (_) {}
351
+ }
352
+ for (const id of createdEnduserIds) {
353
+ try { await sdk.api.endusers.deleteOne(id) } catch (_) {}
354
+ }
355
+ for (const id of createdTriggerIds) {
356
+ try { await sdk.api.automation_triggers.deleteOne(id) } catch (_) {}
357
+ }
358
+ }
359
+ }
360
+
361
+ // Allow running this test file independently
362
+ if (require.main === module) {
363
+ console.log(`🌐 Using API URL: ${host}`)
364
+ const sdk = new Session({ host })
365
+ const sdkNonAdmin = new Session({ host })
366
+
367
+ const runTests = async () => {
368
+ await setup_tests(sdk, sdkNonAdmin)
369
+ await group_event_attendee_status_triggers_tests({ sdk, sdkNonAdmin })
370
+ }
371
+
372
+ runTests()
373
+ .then(() => {
374
+ console.log("✅ Group event per-attendee status trigger tests completed successfully")
375
+ process.exit(0)
376
+ })
377
+ .catch((error) => {
378
+ console.error("❌ Group event per-attendee status trigger tests failed:", error)
379
+ process.exit(1)
380
+ })
381
+ }
@@ -48,6 +48,8 @@ import { concurrent_build_threads_tests } from "./api_tests/concurrent_build_thr
48
48
  import { appointment_completed_trigger_tests } from "./api_tests/appointment_completed_trigger.test"
49
49
  import { purchase_made_trigger_tests } from "./api_tests/purchase_made_trigger.test"
50
50
  import { appointment_rescheduled_trigger_tests } from "./api_tests/appointment_rescheduled_trigger.test"
51
+ import { appointment_no_showed_trigger_tests } from "./api_tests/appointment_no_showed_trigger.test"
52
+ import { group_event_attendee_status_triggers_tests } from "./api_tests/group_event_attendee_status_triggers.test"
51
53
  import { journey_error_branching_tests } from "./api_tests/journey_error_branching.test"
52
54
  import { afteraction_day_of_month_delay_tests } from "./api_tests/afteraction_day_of_month_delay.test"
53
55
  import { push_forms_to_portal_group_completion_tests } from "./api_tests/push_forms_to_portal_group_completion.test"
@@ -5711,6 +5713,8 @@ const trigger_events_api_tests = async () => {
5711
5713
  const automation_trigger_tests = async () => {
5712
5714
  log_header("Automation Trigger Tests")
5713
5715
 
5716
+ await appointment_no_showed_trigger_tests({ sdk, sdkNonAdmin })
5717
+ await group_event_attendee_status_triggers_tests({ sdk, sdkNonAdmin })
5714
5718
  await assign_care_team_tests()
5715
5719
  await push_forms_to_portal_group_completion_tests({ sdk, sdkNonAdmin })
5716
5720
  await order_status_equals_tests()
@@ -11423,6 +11427,7 @@ const lockout_tests = async () => {
11423
11427
  () => sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: -1 }),
11424
11428
  passOnAnyResult
11425
11429
  )
11430
+ await wait(undefined, 1500) // ensure new token's iat second clears the stale deauthenticated-<id> cache marker (1s tolerance)
11426
11431
  await async_test(
11427
11432
  "non-admin can re authenciate when locked to 0",
11428
11433
  () => sdkNonAdmin.authenticate(nonAdminEmail, nonAdminPassword),
@@ -11455,6 +11460,7 @@ const lockout_tests = async () => {
11455
11460
  () => sdk.api.users.updateOne(nonAdminId, { lockedOutUntil: -1 }),
11456
11461
  passOnAnyResult
11457
11462
  )
11463
+ await wait(undefined, 1500) // ensure new token's iat second clears the stale deauthenticated-<id> cache marker (1s tolerance)
11458
11464
  await async_test(
11459
11465
  "non-admin can re authenciate when locked to future date",
11460
11466
  () => sdkNonAdmin.authenticate(nonAdminEmail, nonAdminPassword),
@@ -15028,6 +15034,7 @@ const ip_address_form_tests = async () => {
15028
15034
  await replace_form_field_template_values_tests()
15029
15035
  await mfa_tests()
15030
15036
  await setup_tests(sdk, sdkNonAdmin)
15037
+ await automation_trigger_tests()
15031
15038
  await resource_access_tags_tests({ sdk, sdkNonAdmin })
15032
15039
  await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
15033
15040
  await beluga_pharmacy_mappings_tests({ sdk, sdkNonAdmin })
@@ -15038,7 +15045,6 @@ const ip_address_form_tests = async () => {
15038
15045
  await load_inbox_redaction_tests({ sdk, sdkNonAdmin })
15039
15046
  await webhook_timeout_tests({ sdk, sdkNonAdmin })
15040
15047
  await user_portal_settings_tests({ sdk, sdkNonAdmin })
15041
- await automation_trigger_tests()
15042
15048
  await invite_user_enumeration_tests({ sdk, sdkNonAdmin })
15043
15049
  await handle_incoming_communication_cross_tenant_tests({ sdk, sdkNonAdmin })
15044
15050
  await calendar_event_webhook_template_tests({ sdk, sdkNonAdmin })
Binary file