@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,491 @@
1
+ import OnCallShiftChangeListeners, {
2
+ OnCallShiftChangeEvent,
3
+ OnCallShiftChangeReason,
4
+ } from "../../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
5
+ import OnCallShiftReminderRunner, {
6
+ SHIFT_REMINDER_LISTENER_NAME,
7
+ ShiftReminderChangePassStats,
8
+ } from "../../../../Server/Utils/OnCall/OnCallShiftReminderRunner";
9
+ import logger from "../../../../Server/Utils/Logger";
10
+ import ObjectID from "../../../../Types/ObjectID";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import {
14
+ afterEach,
15
+ beforeEach,
16
+ describe,
17
+ expect,
18
+ jest,
19
+ test,
20
+ } from "@jest/globals";
21
+
22
+ /*
23
+ * OnCallShiftReminderListener wires the change pass (catch-up + reassigned
24
+ * notices) into the on-call configuration hooks through
25
+ * OnCallShiftChangeListeners. Pinned here:
26
+ *
27
+ * 1. importing the module registers the listener under the runner's name
28
+ * (a side effect, like a RunCron job file) and doing so twice never
29
+ * stacks a second listener;
30
+ * 2. the registered function hands the event to
31
+ * OnCallShiftReminderRunner.runChangePass;
32
+ * 3. a change pass that throws is caught and logged — the hook that
33
+ * published the event can neither fail nor be delayed by it;
34
+ * 4. BOTH App/Index.ts and App/FeatureSet/Workers/Index.ts import the
35
+ * module, because the hooks run in whichever process serves the CRUD
36
+ * request (the api role for a dashboard edit);
37
+ * 5. bursts are coalesced: one pass per project at a time, everything that
38
+ * arrives while it runs merged into ONE follow-up, so bulk edits cannot
39
+ * pile N synchronous schedule expansions onto the api event loop.
40
+ */
41
+
42
+ // Imported last so the registration side effect is what the tests observe.
43
+ import registerOnCallShiftReminderListener, {
44
+ mergeOnCallShiftChangeEvents,
45
+ onCallShiftReminderListener,
46
+ resetOnCallShiftReminderCoalescing,
47
+ } from "../../../../Server/Utils/OnCall/OnCallShiftReminderListener";
48
+
49
+ const COMMON_DIR: string = path.resolve(__dirname, "../../../..");
50
+ const APP_DIR: string = path.resolve(COMMON_DIR, "../App");
51
+ const LISTENER_PATH: string = path.join(
52
+ COMMON_DIR,
53
+ "Server",
54
+ "Utils",
55
+ "OnCall",
56
+ "OnCallShiftReminderListener.ts",
57
+ );
58
+
59
+ const LISTENER_SPECIFIER: string =
60
+ "Common/Server/Utils/OnCall/OnCallShiftReminderListener";
61
+
62
+ function escapeForRegExp(value: string): string {
63
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ }
65
+
66
+ const SIDE_EFFECT_IMPORT: RegExp = new RegExp(
67
+ `^\\s*import\\s+["']${escapeForRegExp(LISTENER_SPECIFIER)}["']\\s*;?\\s*$`,
68
+ "m",
69
+ );
70
+
71
+ function event(): OnCallShiftChangeEvent {
72
+ return OnCallShiftChangeListeners.buildEvent({
73
+ projectId: new ObjectID("project-1"),
74
+ scheduleIds: [new ObjectID("schedule-1")],
75
+ userIds: [new ObjectID("user-a")],
76
+ reason: OnCallShiftChangeReason.OverrideChanged,
77
+ });
78
+ }
79
+
80
+ function eventFor(data: {
81
+ projectId: string | null;
82
+ scheduleId: string;
83
+ userId: string;
84
+ reason?: OnCallShiftChangeReason | undefined;
85
+ occurredAt?: Date | undefined;
86
+ }): OnCallShiftChangeEvent {
87
+ return OnCallShiftChangeListeners.buildEvent({
88
+ projectId: data.projectId ? new ObjectID(data.projectId) : null,
89
+ scheduleIds: [new ObjectID(data.scheduleId)],
90
+ userIds: [new ObjectID(data.userId)],
91
+ reason: data.reason ?? OnCallShiftChangeReason.LayerUserChanged,
92
+ occurredAt: data.occurredAt,
93
+ });
94
+ }
95
+
96
+ function idsOf(ids: Array<ObjectID>): Array<string> {
97
+ return ids
98
+ .map((id: ObjectID) => {
99
+ return id.toString();
100
+ })
101
+ .sort();
102
+ }
103
+
104
+ function emptyStats(): ShiftReminderChangePassStats {
105
+ return {
106
+ now: new Date(),
107
+ projectId: "project-1",
108
+ users: 0,
109
+ catchUpsSent: 0,
110
+ reassignedSent: 0,
111
+ claimCollisions: 0,
112
+ sendFailures: 0,
113
+ missingSettings: 0,
114
+ errors: 0,
115
+ skippedReason: null,
116
+ };
117
+ }
118
+
119
+ describe("OnCallShiftReminderListener", () => {
120
+ beforeEach(() => {
121
+ resetOnCallShiftReminderCoalescing();
122
+ jest.spyOn(logger, "error").mockImplementation((): void => {
123
+ return undefined;
124
+ });
125
+ });
126
+
127
+ afterEach(() => {
128
+ resetOnCallShiftReminderCoalescing();
129
+ jest.restoreAllMocks();
130
+ });
131
+
132
+ test("importing the module registers the change pass under the runner's listener name", () => {
133
+ expect(SHIFT_REMINDER_LISTENER_NAME).toBe("shift-reminders");
134
+ expect(OnCallShiftChangeListeners.getRegisteredNames()).toContain(
135
+ SHIFT_REMINDER_LISTENER_NAME,
136
+ );
137
+ });
138
+
139
+ test("registering again (a second import, another role) never stacks a second listener", () => {
140
+ const before: number = OnCallShiftChangeListeners.getCount();
141
+
142
+ registerOnCallShiftReminderListener();
143
+ registerOnCallShiftReminderListener();
144
+
145
+ expect(OnCallShiftChangeListeners.getCount()).toBe(before);
146
+ expect(
147
+ OnCallShiftChangeListeners.getRegisteredNames().filter(
148
+ (name: string | undefined) => {
149
+ return name === SHIFT_REMINDER_LISTENER_NAME;
150
+ },
151
+ ),
152
+ ).toHaveLength(1);
153
+ });
154
+
155
+ test("the listener hands the event to OnCallShiftReminderRunner.runChangePass", async () => {
156
+ const runChangePass: any = jest
157
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
158
+ .mockResolvedValue(emptyStats());
159
+
160
+ const published: OnCallShiftChangeEvent = event();
161
+
162
+ await onCallShiftReminderListener(published);
163
+
164
+ expect(runChangePass).toHaveBeenCalledTimes(1);
165
+ expect(runChangePass.mock.calls[0]![0]).toBe(published);
166
+ });
167
+
168
+ test("a change pass reached through notify() runs for a published event", async () => {
169
+ const runChangePass: any = jest
170
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
171
+ .mockResolvedValue(emptyStats());
172
+
173
+ await OnCallShiftChangeListeners.notify(event());
174
+
175
+ expect(runChangePass).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ test("a throwing change pass is caught and logged; the listener itself resolves", async () => {
179
+ jest
180
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
181
+ .mockRejectedValue(new Error("boom"));
182
+
183
+ await expect(onCallShiftReminderListener(event())).resolves.toBeUndefined();
184
+
185
+ const errorLog: any = logger.error as any;
186
+
187
+ expect(errorLog).toHaveBeenCalled();
188
+ expect(
189
+ errorLog.mock.calls.some((call: Array<unknown>) => {
190
+ return String(call[0]).includes(SHIFT_REMINDER_LISTENER_NAME);
191
+ }),
192
+ ).toBe(true);
193
+ });
194
+
195
+ test("a synchronous throw inside runChangePass is caught too", async () => {
196
+ jest
197
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
198
+ .mockImplementation((): Promise<ShiftReminderChangePassStats> => {
199
+ throw new TypeError("sync boom");
200
+ });
201
+
202
+ await expect(onCallShiftReminderListener(event())).resolves.toBeUndefined();
203
+ });
204
+
205
+ /*
206
+ * Coalescing. The hooks run on the api tier, and every pass re-materializes
207
+ * the affected schedules synchronously, so a burst of edits (ten users
208
+ * added to a layer is ten create hooks) must not become ten passes.
209
+ */
210
+ describe("coalescing bursts", () => {
211
+ interface GatedRun {
212
+ events: Array<OnCallShiftChangeEvent>;
213
+ release: () => void;
214
+ }
215
+
216
+ /** runChangePass that blocks on the FIRST call until released. */
217
+ function gateFirstPass(): GatedRun {
218
+ const events: Array<OnCallShiftChangeEvent> = [];
219
+ let release: () => void = (): void => {
220
+ return undefined;
221
+ };
222
+
223
+ const gate: Promise<void> = new Promise<void>((resolve: () => void) => {
224
+ release = resolve;
225
+ });
226
+
227
+ jest
228
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
229
+ .mockImplementation((async (
230
+ published: OnCallShiftChangeEvent,
231
+ ): Promise<ShiftReminderChangePassStats> => {
232
+ events.push(published);
233
+
234
+ if (events.length === 1) {
235
+ await gate;
236
+ }
237
+
238
+ return emptyStats();
239
+ }) as never);
240
+
241
+ return { events, release };
242
+ }
243
+
244
+ test("a burst for one project costs two passes, and the follow-up carries the union of what queued", async () => {
245
+ const run: GatedRun = gateFirstPass();
246
+
247
+ const first: Promise<void> = onCallShiftReminderListener(
248
+ eventFor({
249
+ projectId: "project-1",
250
+ scheduleId: "schedule-1",
251
+ userId: "user-a",
252
+ }),
253
+ );
254
+ const second: Promise<void> = onCallShiftReminderListener(
255
+ eventFor({
256
+ projectId: "project-1",
257
+ scheduleId: "schedule-1",
258
+ userId: "user-b",
259
+ }),
260
+ );
261
+ const third: Promise<void> = onCallShiftReminderListener(
262
+ eventFor({
263
+ projectId: "project-1",
264
+ scheduleId: "schedule-2",
265
+ userId: "user-c",
266
+ reason: OnCallShiftChangeReason.LayerChanged,
267
+ }),
268
+ );
269
+
270
+ run.release();
271
+
272
+ await Promise.all([first, second, third]);
273
+
274
+ expect(run.events).toHaveLength(2);
275
+ expect(idsOf(run.events[0]!.userIds)).toEqual(["user-a"]);
276
+
277
+ // The merged follow-up: both queued events, nothing lost.
278
+ expect(idsOf(run.events[1]!.userIds)).toEqual(["user-b", "user-c"]);
279
+ expect(idsOf(run.events[1]!.scheduleIds)).toEqual([
280
+ "schedule-1",
281
+ "schedule-2",
282
+ ]);
283
+ expect(run.events[1]!.reason).toBe(OnCallShiftChangeReason.LayerChanged);
284
+ });
285
+
286
+ test("every caller's promise settles only after the pass covering its event has run", async () => {
287
+ const run: GatedRun = gateFirstPass();
288
+
289
+ let secondSettled: boolean = false;
290
+
291
+ const first: Promise<void> = onCallShiftReminderListener(
292
+ eventFor({
293
+ projectId: "project-1",
294
+ scheduleId: "schedule-1",
295
+ userId: "user-a",
296
+ }),
297
+ );
298
+ const second: Promise<void> = onCallShiftReminderListener(
299
+ eventFor({
300
+ projectId: "project-1",
301
+ scheduleId: "schedule-1",
302
+ userId: "user-b",
303
+ }),
304
+ ).then((): void => {
305
+ secondSettled = true;
306
+ });
307
+
308
+ expect(secondSettled).toBe(false);
309
+ expect(run.events).toHaveLength(1);
310
+
311
+ run.release();
312
+ await Promise.all([first, second]);
313
+
314
+ expect(secondSettled).toBe(true);
315
+ expect(run.events).toHaveLength(2);
316
+ });
317
+
318
+ test("different projects are never queued behind each other", async () => {
319
+ const run: GatedRun = gateFirstPass();
320
+
321
+ const first: Promise<void> = onCallShiftReminderListener(
322
+ eventFor({
323
+ projectId: "project-1",
324
+ scheduleId: "schedule-1",
325
+ userId: "user-a",
326
+ }),
327
+ );
328
+ const other: Promise<void> = onCallShiftReminderListener(
329
+ eventFor({
330
+ projectId: "project-2",
331
+ scheduleId: "schedule-9",
332
+ userId: "user-z",
333
+ }),
334
+ );
335
+
336
+ // The second project ran while the first was still blocked.
337
+ await other;
338
+ expect(run.events).toHaveLength(2);
339
+ expect(run.events[1]!.projectId?.toString()).toBe("project-2");
340
+
341
+ run.release();
342
+ await first;
343
+ });
344
+
345
+ test("an event that names no project is never coalesced (the pass resolves it from the schedules)", async () => {
346
+ const run: GatedRun = gateFirstPass();
347
+
348
+ const first: Promise<void> = onCallShiftReminderListener(
349
+ eventFor({
350
+ projectId: null,
351
+ scheduleId: "schedule-1",
352
+ userId: "user-a",
353
+ }),
354
+ );
355
+ const second: Promise<void> = onCallShiftReminderListener(
356
+ eventFor({
357
+ projectId: null,
358
+ scheduleId: "schedule-2",
359
+ userId: "user-b",
360
+ }),
361
+ );
362
+
363
+ await second;
364
+ expect(run.events).toHaveLength(2);
365
+
366
+ run.release();
367
+ await first;
368
+ });
369
+
370
+ test("a queued follow-up still runs when the pass before it throws", async () => {
371
+ const events: Array<OnCallShiftChangeEvent> = [];
372
+ let release: () => void = (): void => {
373
+ return undefined;
374
+ };
375
+ const gate: Promise<void> = new Promise<void>((resolve: () => void) => {
376
+ release = resolve;
377
+ });
378
+
379
+ jest
380
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
381
+ .mockImplementation((async (
382
+ published: OnCallShiftChangeEvent,
383
+ ): Promise<ShiftReminderChangePassStats> => {
384
+ events.push(published);
385
+
386
+ if (events.length === 1) {
387
+ await gate;
388
+ throw new Error("boom");
389
+ }
390
+
391
+ return emptyStats();
392
+ }) as never);
393
+
394
+ const first: Promise<void> = onCallShiftReminderListener(
395
+ eventFor({
396
+ projectId: "project-1",
397
+ scheduleId: "schedule-1",
398
+ userId: "user-a",
399
+ }),
400
+ );
401
+ const second: Promise<void> = onCallShiftReminderListener(
402
+ eventFor({
403
+ projectId: "project-1",
404
+ scheduleId: "schedule-1",
405
+ userId: "user-b",
406
+ }),
407
+ );
408
+
409
+ release();
410
+
411
+ await expect(first).resolves.toBeUndefined();
412
+ await expect(second).resolves.toBeUndefined();
413
+ expect(events).toHaveLength(2);
414
+ expect(idsOf(events[1]!.userIds)).toEqual(["user-b"]);
415
+ });
416
+
417
+ test("a later burst after the queue drained starts a fresh pass", async () => {
418
+ jest
419
+ .spyOn(OnCallShiftReminderRunner, "runChangePass")
420
+ .mockResolvedValue(emptyStats());
421
+
422
+ await onCallShiftReminderListener(event());
423
+ await onCallShiftReminderListener(event());
424
+
425
+ expect(
426
+ (OnCallShiftReminderRunner.runChangePass as any).mock.calls,
427
+ ).toHaveLength(2);
428
+ });
429
+
430
+ test("mergeOnCallShiftChangeEvents unions the ids and keeps the newest occurredAt", () => {
431
+ const older: OnCallShiftChangeEvent = eventFor({
432
+ projectId: "project-1",
433
+ scheduleId: "schedule-1",
434
+ userId: "user-a",
435
+ reason: OnCallShiftChangeReason.LayerUserChanged,
436
+ occurredAt: new Date("2026-09-03T15:00:00Z"),
437
+ });
438
+ const newer: OnCallShiftChangeEvent = eventFor({
439
+ projectId: "project-1",
440
+ scheduleId: "schedule-2",
441
+ userId: "user-a",
442
+ reason: OnCallShiftChangeReason.OverrideChanged,
443
+ occurredAt: new Date("2026-09-03T15:00:05Z"),
444
+ });
445
+
446
+ const merged: OnCallShiftChangeEvent = mergeOnCallShiftChangeEvents(
447
+ older,
448
+ newer,
449
+ );
450
+
451
+ expect(merged.projectId?.toString()).toBe("project-1");
452
+ expect(idsOf(merged.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
453
+ // Deduplicated, not repeated.
454
+ expect(idsOf(merged.userIds)).toEqual(["user-a"]);
455
+ expect(merged.reason).toBe(OnCallShiftChangeReason.OverrideChanged);
456
+ expect(merged.occurredAt.toISOString()).toBe("2026-09-03T15:00:05.000Z");
457
+ });
458
+ });
459
+
460
+ describe("wiring", () => {
461
+ test("the module exists where the two entrypoints import it from", () => {
462
+ expect(fs.existsSync(LISTENER_PATH)).toBe(true);
463
+ });
464
+
465
+ test("App/FeatureSet/Workers/Index.ts imports the listener module as a side effect", () => {
466
+ const source: string = fs.readFileSync(
467
+ path.join(APP_DIR, "FeatureSet", "Workers", "Index.ts"),
468
+ "utf8",
469
+ );
470
+
471
+ expect(source).toMatch(SIDE_EFFECT_IMPORT);
472
+ });
473
+
474
+ test("App/Index.ts imports the listener module too, so the api role runs the change pass", () => {
475
+ const source: string = fs.readFileSync(
476
+ path.join(APP_DIR, "Index.ts"),
477
+ "utf8",
478
+ );
479
+
480
+ expect(source).toMatch(SIDE_EFFECT_IMPORT);
481
+ });
482
+
483
+ test("the listener module registers at import time (a plain function nobody calls would be dead code)", () => {
484
+ const source: string = fs.readFileSync(LISTENER_PATH, "utf8");
485
+
486
+ expect(source).toMatch(/^registerOnCallShiftReminderListener\(\);\s*$/m);
487
+ expect(source).toContain("OnCallShiftChangeListeners.register(");
488
+ expect(source).toContain("SHIFT_REMINDER_LISTENER_NAME");
489
+ });
490
+ });
491
+ });