@tellescope/sdk 1.254.1 → 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 (69) hide show
  1. package/lib/cjs/sdk.d.ts +4 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +5 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.js +236 -0
  8. package/lib/cjs/tests/api_tests/appointment_no_showed_trigger.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.d.ts.map +1 -1
  10. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.js +108 -73
  11. package/lib/cjs/tests/api_tests/calendar_events_bulk_update.test.js.map +1 -1
  12. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts +6 -0
  13. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -0
  14. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js +587 -0
  15. package/lib/cjs/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -0
  16. package/lib/cjs/tests/api_tests/healthie_force_sync.test.d.ts +6 -0
  17. package/lib/cjs/tests/api_tests/healthie_force_sync.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/healthie_force_sync.test.js +165 -0
  19. package/lib/cjs/tests/api_tests/healthie_force_sync.test.js.map +1 -0
  20. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
  21. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js +254 -0
  23. package/lib/cjs/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts +6 -0
  25. package/lib/cjs/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/organization_api_keys.test.js +226 -0
  27. package/lib/cjs/tests/api_tests/organization_api_keys.test.js.map +1 -0
  28. package/lib/cjs/tests/tests.d.ts.map +1 -1
  29. package/lib/cjs/tests/tests.js +560 -305
  30. package/lib/cjs/tests/tests.js.map +1 -1
  31. package/lib/esm/sdk.d.ts +6 -2
  32. package/lib/esm/sdk.d.ts.map +1 -1
  33. package/lib/esm/sdk.js +5 -0
  34. package/lib/esm/sdk.js.map +1 -1
  35. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.d.ts +6 -0
  36. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.d.ts.map +1 -0
  37. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.js +232 -0
  38. package/lib/esm/tests/api_tests/appointment_no_showed_trigger.test.js.map +1 -0
  39. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.d.ts.map +1 -1
  40. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.js +109 -74
  41. package/lib/esm/tests/api_tests/calendar_events_bulk_update.test.js.map +1 -1
  42. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts +6 -0
  43. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.d.ts.map +1 -0
  44. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js +583 -0
  45. package/lib/esm/tests/api_tests/group_event_attendee_status_triggers.test.js.map +1 -0
  46. package/lib/esm/tests/api_tests/healthie_force_sync.test.d.ts +6 -0
  47. package/lib/esm/tests/api_tests/healthie_force_sync.test.d.ts.map +1 -0
  48. package/lib/esm/tests/api_tests/healthie_force_sync.test.js +161 -0
  49. package/lib/esm/tests/api_tests/healthie_force_sync.test.js.map +1 -0
  50. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts +6 -0
  51. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.d.ts.map +1 -0
  52. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js +250 -0
  53. package/lib/esm/tests/api_tests/order_status_equals_frequency.test.js.map +1 -0
  54. package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts +6 -0
  55. package/lib/esm/tests/api_tests/organization_api_keys.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/organization_api_keys.test.js +222 -0
  57. package/lib/esm/tests/api_tests/organization_api_keys.test.js.map +1 -0
  58. package/lib/esm/tests/tests.d.ts.map +1 -1
  59. package/lib/esm/tests/tests.js +560 -305
  60. package/lib/esm/tests/tests.js.map +1 -1
  61. package/lib/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +10 -10
  63. package/src/sdk.ts +11 -0
  64. package/src/tests/api_tests/appointment_no_showed_trigger.test.ts +144 -0
  65. package/src/tests/api_tests/calendar_events_bulk_update.test.ts +22 -0
  66. package/src/tests/api_tests/group_event_attendee_status_triggers.test.ts +381 -0
  67. package/src/tests/api_tests/organization_api_keys.test.ts +207 -0
  68. package/src/tests/tests.ts +196 -3
  69. 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
+ }