@oneuptime/common 12.0.29 → 12.0.30

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 (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,314 @@
1
+ import OnCallShiftChangeListeners, {
2
+ OnCallShiftChangeEvent,
3
+ OnCallShiftChangeReason,
4
+ } from "../../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
5
+ import logger from "../../../../Server/Utils/Logger";
6
+ import ObjectID from "../../../../Types/ObjectID";
7
+ import {
8
+ afterEach,
9
+ beforeEach,
10
+ describe,
11
+ expect,
12
+ jest,
13
+ test,
14
+ } from "@jest/globals";
15
+
16
+ const PROJECT_ID: ObjectID = new ObjectID("project-1");
17
+ const SCHEDULE_A: ObjectID = new ObjectID("schedule-a");
18
+ const SCHEDULE_B: ObjectID = new ObjectID("schedule-b");
19
+ const USER_1: ObjectID = new ObjectID("user-1");
20
+ const USER_2: ObjectID = new ObjectID("user-2");
21
+
22
+ function event(
23
+ overrides?: Partial<OnCallShiftChangeEvent>,
24
+ ): OnCallShiftChangeEvent {
25
+ return {
26
+ projectId: PROJECT_ID,
27
+ scheduleIds: [SCHEDULE_A],
28
+ userIds: [USER_1],
29
+ reason: OnCallShiftChangeReason.LayerChanged,
30
+ occurredAt: new Date("2026-08-31T10:00:00Z"),
31
+ ...(overrides || {}),
32
+ };
33
+ }
34
+
35
+ describe("OnCallShiftChangeListeners", () => {
36
+ beforeEach(() => {
37
+ OnCallShiftChangeListeners.clear();
38
+ jest.spyOn(logger, "error").mockImplementation((): void => {
39
+ return undefined;
40
+ });
41
+ });
42
+
43
+ afterEach(() => {
44
+ OnCallShiftChangeListeners.clear();
45
+ jest.restoreAllMocks();
46
+ });
47
+
48
+ describe("register", () => {
49
+ test("registers a listener and delivers events to it", async () => {
50
+ const received: Array<OnCallShiftChangeEvent> = [];
51
+
52
+ OnCallShiftChangeListeners.register((e: OnCallShiftChangeEvent) => {
53
+ received.push(e);
54
+ });
55
+
56
+ const sent: OnCallShiftChangeEvent = event();
57
+ await OnCallShiftChangeListeners.notify(sent);
58
+
59
+ expect(received).toHaveLength(1);
60
+ expect(received[0]).toBe(sent);
61
+ expect(OnCallShiftChangeListeners.getCount()).toBe(1);
62
+ });
63
+
64
+ test("registering the same function twice without a name is a no-op", async () => {
65
+ let calls: number = 0;
66
+ const listener: () => void = (): void => {
67
+ calls++;
68
+ };
69
+
70
+ OnCallShiftChangeListeners.register(listener);
71
+ OnCallShiftChangeListeners.register(listener);
72
+
73
+ expect(OnCallShiftChangeListeners.getCount()).toBe(1);
74
+
75
+ await OnCallShiftChangeListeners.notify(event());
76
+ expect(calls).toBe(1);
77
+ });
78
+
79
+ test("re-registering under the same name replaces the earlier listener", async () => {
80
+ const seen: Array<string> = [];
81
+
82
+ OnCallShiftChangeListeners.register((): void => {
83
+ seen.push("first");
84
+ }, "reminders");
85
+ OnCallShiftChangeListeners.register((): void => {
86
+ seen.push("second");
87
+ }, "reminders");
88
+
89
+ expect(OnCallShiftChangeListeners.getCount()).toBe(1);
90
+ expect(OnCallShiftChangeListeners.getRegisteredNames()).toEqual([
91
+ "reminders",
92
+ ]);
93
+
94
+ await OnCallShiftChangeListeners.notify(event());
95
+ expect(seen).toEqual(["second"]);
96
+ });
97
+
98
+ test("two different names are two listeners", async () => {
99
+ const seen: Array<string> = [];
100
+
101
+ OnCallShiftChangeListeners.register((): void => {
102
+ seen.push("a");
103
+ }, "a");
104
+ OnCallShiftChangeListeners.register((): void => {
105
+ seen.push("b");
106
+ }, "b");
107
+
108
+ await OnCallShiftChangeListeners.notify(event());
109
+ expect(seen.sort()).toEqual(["a", "b"]);
110
+ });
111
+
112
+ test("rejects a non-function", () => {
113
+ expect(() => {
114
+ OnCallShiftChangeListeners.register(
115
+ "nope" as unknown as () => void,
116
+ "bad",
117
+ );
118
+ }).toThrow();
119
+ expect(OnCallShiftChangeListeners.getCount()).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe("unregister / clear", () => {
124
+ test("unregister by name", async () => {
125
+ let calls: number = 0;
126
+ OnCallShiftChangeListeners.register((): void => {
127
+ calls++;
128
+ }, "named");
129
+
130
+ OnCallShiftChangeListeners.unregister("named");
131
+ expect(OnCallShiftChangeListeners.getCount()).toBe(0);
132
+
133
+ await OnCallShiftChangeListeners.notify(event());
134
+ expect(calls).toBe(0);
135
+ });
136
+
137
+ test("unregister by function reference", async () => {
138
+ let calls: number = 0;
139
+ const listener: () => void = (): void => {
140
+ calls++;
141
+ };
142
+ OnCallShiftChangeListeners.register(listener);
143
+
144
+ OnCallShiftChangeListeners.unregister(listener);
145
+
146
+ await OnCallShiftChangeListeners.notify(event());
147
+ expect(calls).toBe(0);
148
+ });
149
+
150
+ test("unregistering an unknown name or function is harmless", () => {
151
+ OnCallShiftChangeListeners.register((): void => {
152
+ return undefined;
153
+ }, "keep");
154
+
155
+ OnCallShiftChangeListeners.unregister("unknown");
156
+ OnCallShiftChangeListeners.unregister((): void => {
157
+ return undefined;
158
+ });
159
+
160
+ expect(OnCallShiftChangeListeners.getCount()).toBe(1);
161
+ });
162
+
163
+ test("clear drops everything", () => {
164
+ OnCallShiftChangeListeners.register((): void => {
165
+ return undefined;
166
+ }, "x");
167
+ OnCallShiftChangeListeners.register((): void => {
168
+ return undefined;
169
+ });
170
+
171
+ OnCallShiftChangeListeners.clear();
172
+ expect(OnCallShiftChangeListeners.getCount()).toBe(0);
173
+ });
174
+ });
175
+
176
+ describe("notify", () => {
177
+ test("resolves with no listeners registered", async () => {
178
+ await expect(
179
+ OnCallShiftChangeListeners.notify(event()),
180
+ ).resolves.toBeUndefined();
181
+ });
182
+
183
+ test("awaits async listeners", async () => {
184
+ let finished: boolean = false;
185
+
186
+ OnCallShiftChangeListeners.register(async (): Promise<void> => {
187
+ await new Promise<void>((resolve: () => void) => {
188
+ setTimeout(resolve, 5);
189
+ });
190
+ finished = true;
191
+ });
192
+
193
+ await OnCallShiftChangeListeners.notify(event());
194
+ expect(finished).toBe(true);
195
+ });
196
+
197
+ test("a throwing listener is logged and the others still run", async () => {
198
+ const seen: Array<string> = [];
199
+
200
+ OnCallShiftChangeListeners.register((): void => {
201
+ throw new Error("boom");
202
+ }, "broken");
203
+ OnCallShiftChangeListeners.register((): void => {
204
+ seen.push("healthy");
205
+ }, "healthy");
206
+
207
+ await expect(
208
+ OnCallShiftChangeListeners.notify(event()),
209
+ ).resolves.toBeUndefined();
210
+
211
+ expect(seen).toEqual(["healthy"]);
212
+ expect(logger.error).toHaveBeenCalled();
213
+
214
+ const messages: Array<string> = (
215
+ logger.error as unknown as jest.Mock
216
+ ).mock.calls.map((call: Array<unknown>) => {
217
+ return String(call[0]);
218
+ });
219
+ expect(
220
+ messages.some((message: string) => {
221
+ return message.includes('"broken"');
222
+ }),
223
+ ).toBe(true);
224
+ });
225
+
226
+ test("a rejecting async listener is logged and swallowed", async () => {
227
+ OnCallShiftChangeListeners.register(async (): Promise<void> => {
228
+ throw new Error("async boom");
229
+ });
230
+
231
+ await expect(
232
+ OnCallShiftChangeListeners.notify(event()),
233
+ ).resolves.toBeUndefined();
234
+ expect(logger.error).toHaveBeenCalled();
235
+ });
236
+
237
+ test("a listener that unregisters itself during delivery does not break delivery", async () => {
238
+ const seen: Array<string> = [];
239
+
240
+ OnCallShiftChangeListeners.register((): void => {
241
+ OnCallShiftChangeListeners.unregister("self");
242
+ seen.push("self");
243
+ }, "self");
244
+ OnCallShiftChangeListeners.register((): void => {
245
+ seen.push("other");
246
+ }, "other");
247
+
248
+ await OnCallShiftChangeListeners.notify(event());
249
+
250
+ expect(seen.sort()).toEqual(["other", "self"]);
251
+ expect(OnCallShiftChangeListeners.getRegisteredNames()).toEqual([
252
+ "other",
253
+ ]);
254
+ });
255
+ });
256
+
257
+ describe("buildEvent / dedupe", () => {
258
+ test("dedupes schedule and user ids and defaults occurredAt to now", () => {
259
+ const before: number = Date.now();
260
+
261
+ const built: OnCallShiftChangeEvent =
262
+ OnCallShiftChangeListeners.buildEvent({
263
+ projectId: PROJECT_ID,
264
+ scheduleIds: [SCHEDULE_A, SCHEDULE_B, new ObjectID("schedule-a")],
265
+ userIds: [USER_1, USER_1, USER_2],
266
+ reason: OnCallShiftChangeReason.OverrideChanged,
267
+ });
268
+
269
+ expect(
270
+ built.scheduleIds.map((id: ObjectID) => {
271
+ return id.toString();
272
+ }),
273
+ ).toEqual(["schedule-a", "schedule-b"]);
274
+ expect(
275
+ built.userIds.map((id: ObjectID) => {
276
+ return id.toString();
277
+ }),
278
+ ).toEqual(["user-1", "user-2"]);
279
+ expect(built.reason).toBe(OnCallShiftChangeReason.OverrideChanged);
280
+ expect(built.projectId).toBe(PROJECT_ID);
281
+ expect(built.occurredAt.getTime()).toBeGreaterThanOrEqual(before);
282
+ });
283
+
284
+ test("a missing project becomes null and missing userIds become []", () => {
285
+ const built: OnCallShiftChangeEvent =
286
+ OnCallShiftChangeListeners.buildEvent({
287
+ projectId: undefined,
288
+ scheduleIds: [],
289
+ reason: OnCallShiftChangeReason.ScheduleDeleted,
290
+ occurredAt: new Date("2026-01-01T00:00:00Z"),
291
+ });
292
+
293
+ expect(built.projectId).toBeNull();
294
+ expect(built.userIds).toEqual([]);
295
+ expect(built.scheduleIds).toEqual([]);
296
+ expect(built.occurredAt.toISOString()).toBe("2026-01-01T00:00:00.000Z");
297
+ });
298
+
299
+ test("dedupe skips null-ish entries and keeps first occurrence order", () => {
300
+ const result: Array<ObjectID> = OnCallShiftChangeListeners.dedupe([
301
+ USER_2,
302
+ null as unknown as ObjectID,
303
+ USER_1,
304
+ USER_2,
305
+ ]);
306
+
307
+ expect(
308
+ result.map((id: ObjectID) => {
309
+ return id.toString();
310
+ }),
311
+ ).toEqual(["user-2", "user-1"]);
312
+ });
313
+ });
314
+ });