@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,1270 @@
1
+ import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
2
+ import OnCallDutyPolicyScheduleLayerService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerService";
3
+ import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
4
+ import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
5
+ import OnCallDutyPolicyEscalationRuleScheduleService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleScheduleService";
6
+ import OnCallDutyPolicyFeedService from "../../../Server/Services/OnCallDutyPolicyFeedService";
7
+ import OnCallDutyPolicyTimeLogService from "../../../Server/Services/OnCallDutyPolicyTimeLogService";
8
+ import OnCallCalendarFeedCache from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
9
+ import Semaphore from "../../../Server/Infrastructure/Semaphore";
10
+ import OnCallShiftChangeListeners, {
11
+ OnCallShiftChangeEvent,
12
+ OnCallShiftChangeReason,
13
+ } from "../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
14
+ import logger from "../../../Server/Utils/Logger";
15
+ import ObjectID from "../../../Types/ObjectID";
16
+ import {
17
+ afterEach,
18
+ beforeEach,
19
+ describe,
20
+ expect,
21
+ jest,
22
+ test,
23
+ } from "@jest/globals";
24
+
25
+ /*
26
+ * Change propagation for the on-call calendar feeds:
27
+ *
28
+ * - bumpShiftConfigVersion: one atomic increment per distinct schedule,
29
+ * best-effort;
30
+ * - propagateShiftConfigChange: bump + cache purges + listener event;
31
+ * - every configuration hook (layer, layer user, override, policy
32
+ * attachment, schedule rename/timezone, schedule delete) calls it AFTER
33
+ * the roster refresh, with the right schedules, users and reason;
34
+ * - the roster refresh itself NEVER bumps the version.
35
+ *
36
+ * The hooks are protected, so they are invoked through `as any` — the same
37
+ * discipline as OnCallScheduleAttachRosterRefresh.test.ts.
38
+ */
39
+
40
+ const PROJECT_ID: ObjectID = new ObjectID("project-1");
41
+ const SCHEDULE_1: ObjectID = new ObjectID("schedule-1");
42
+ const SCHEDULE_2: ObjectID = new ObjectID("schedule-2");
43
+ const LAYER_1: ObjectID = new ObjectID("layer-1");
44
+ const USER_A: ObjectID = new ObjectID("user-a");
45
+ const USER_B: ObjectID = new ObjectID("user-b");
46
+ const USER_C: ObjectID = new ObjectID("user-c");
47
+ const USER_D: ObjectID = new ObjectID("user-d");
48
+ const ROW_ID: ObjectID = new ObjectID("row-1");
49
+ const POLICY_ID: ObjectID = new ObjectID("policy-1");
50
+ const RULE_ID: ObjectID = new ObjectID("rule-1");
51
+
52
+ const ROSTER_RESULT: {
53
+ currentUserId: null;
54
+ handOffTimeAt: null;
55
+ nextUserId: null;
56
+ nextHandOffTimeAt: null;
57
+ rosterStartAt: null;
58
+ nextRosterStartAt: null;
59
+ } = {
60
+ currentUserId: null,
61
+ handOffTimeAt: null,
62
+ nextUserId: null,
63
+ nextHandOffTimeAt: null,
64
+ rosterStartAt: null,
65
+ nextRosterStartAt: null,
66
+ };
67
+
68
+ function ids(list: Array<ObjectID>): Array<string> {
69
+ return list.map((id: ObjectID) => {
70
+ return id.toString();
71
+ });
72
+ }
73
+
74
+ function silenceLogger(): void {
75
+ jest.spyOn(logger, "error").mockImplementation((): void => {
76
+ return undefined;
77
+ });
78
+ jest.spyOn(logger, "debug").mockImplementation((): void => {
79
+ return undefined;
80
+ });
81
+ }
82
+
83
+ function spyRosterRefresh(): any {
84
+ return jest
85
+ .spyOn(
86
+ OnCallDutyPolicyScheduleService,
87
+ "refreshCurrentUserIdAndHandoffTimeInSchedule",
88
+ )
89
+ .mockResolvedValue(ROSTER_RESULT);
90
+ }
91
+
92
+ function spyPropagate(): any {
93
+ return jest
94
+ .spyOn(OnCallDutyPolicyScheduleService, "propagateShiftConfigChange")
95
+ .mockResolvedValue(undefined);
96
+ }
97
+
98
+ function lastCallArg(spy: any): any {
99
+ return spy.mock.calls[spy.mock.calls.length - 1]![0];
100
+ }
101
+
102
+ function expectCalledAfter(later: any, earlier: any): void {
103
+ const earlierOrder: number = earlier.mock.invocationCallOrder[0]!;
104
+ const laterOrder: number = later.mock.invocationCallOrder[0]!;
105
+ expect(laterOrder).toBeGreaterThan(earlierOrder);
106
+ }
107
+
108
+ describe("bumpShiftConfigVersion", () => {
109
+ beforeEach(silenceLogger);
110
+ afterEach(() => {
111
+ jest.restoreAllMocks();
112
+ });
113
+
114
+ test("adds one to shiftConfigVersion of each DISTINCT schedule with a single atomic statement", async () => {
115
+ const atomic: any = jest
116
+ .spyOn(
117
+ OnCallDutyPolicyScheduleService,
118
+ "atomicAddToColumnsByIdWithoutHooks",
119
+ )
120
+ .mockResolvedValue(undefined);
121
+
122
+ await OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([
123
+ SCHEDULE_1,
124
+ SCHEDULE_2,
125
+ new ObjectID("schedule-1"),
126
+ ]);
127
+
128
+ expect(atomic).toHaveBeenCalledTimes(2);
129
+ expect(atomic.mock.calls[0]![0]).toEqual({
130
+ id: SCHEDULE_1,
131
+ add: { shiftConfigVersion: 1 },
132
+ });
133
+ expect(atomic.mock.calls[1]![0].id.toString()).toBe("schedule-2");
134
+ });
135
+
136
+ test("is best-effort: one failing schedule is logged and the rest are still bumped", async () => {
137
+ const atomic: any = jest
138
+ .spyOn(
139
+ OnCallDutyPolicyScheduleService,
140
+ "atomicAddToColumnsByIdWithoutHooks",
141
+ )
142
+ .mockImplementation((input: any): Promise<void> => {
143
+ if (input.id.toString() === "schedule-1") {
144
+ return Promise.reject(new Error("db down"));
145
+ }
146
+ return Promise.resolve();
147
+ });
148
+
149
+ await expect(
150
+ OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([
151
+ SCHEDULE_1,
152
+ SCHEDULE_2,
153
+ ]),
154
+ ).resolves.toBeUndefined();
155
+
156
+ expect(atomic).toHaveBeenCalledTimes(2);
157
+ expect(logger.error).toHaveBeenCalled();
158
+ });
159
+
160
+ test("does nothing for an empty list", async () => {
161
+ const atomic: any = jest
162
+ .spyOn(
163
+ OnCallDutyPolicyScheduleService,
164
+ "atomicAddToColumnsByIdWithoutHooks",
165
+ )
166
+ .mockResolvedValue(undefined);
167
+
168
+ await OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([]);
169
+ expect(atomic).not.toHaveBeenCalled();
170
+ });
171
+ });
172
+
173
+ describe("propagateShiftConfigChange", () => {
174
+ let bump: any;
175
+ let purgeSchedule: any;
176
+ let purgeUser: any;
177
+ let notify: any;
178
+
179
+ beforeEach(() => {
180
+ silenceLogger();
181
+ bump = jest
182
+ .spyOn(OnCallDutyPolicyScheduleService, "bumpShiftConfigVersion")
183
+ .mockResolvedValue(undefined);
184
+ purgeSchedule = jest
185
+ .spyOn(OnCallCalendarFeedCache, "purgeForSchedule")
186
+ .mockResolvedValue(undefined);
187
+ purgeUser = jest
188
+ .spyOn(OnCallCalendarFeedCache, "purgeForUser")
189
+ .mockResolvedValue(undefined);
190
+ notify = jest
191
+ .spyOn(OnCallShiftChangeListeners, "notify")
192
+ .mockResolvedValue(undefined);
193
+ });
194
+
195
+ afterEach(() => {
196
+ jest.restoreAllMocks();
197
+ });
198
+
199
+ test("bumps, purges the schedule caches and the named users' caches, and notifies with schedule members added", async () => {
200
+ jest
201
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
202
+ .mockResolvedValue([
203
+ { userId: USER_B },
204
+ { userId: USER_C },
205
+ { userId: USER_A },
206
+ ] as never);
207
+
208
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
209
+ scheduleIds: [SCHEDULE_1, SCHEDULE_1, SCHEDULE_2],
210
+ projectId: PROJECT_ID,
211
+ userIds: [USER_A],
212
+ reason: OnCallShiftChangeReason.LayerUserChanged,
213
+ });
214
+
215
+ expect(ids(bump.mock.calls[0]![0])).toEqual(["schedule-1", "schedule-2"]);
216
+
217
+ expect(
218
+ purgeSchedule.mock.calls.map((c: Array<unknown>) => {
219
+ return c[0];
220
+ }),
221
+ ).toEqual(["schedule-1", "schedule-2"]);
222
+ expect(purgeUser).toHaveBeenCalledTimes(1);
223
+ expect(purgeUser).toHaveBeenCalledWith("project-1", "user-a");
224
+
225
+ expect(notify).toHaveBeenCalledTimes(1);
226
+ const event: OnCallShiftChangeEvent = lastCallArg(notify);
227
+ expect(event.projectId).toBe(PROJECT_ID);
228
+ expect(ids(event.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
229
+ // Explicit users first, then the members, deduped.
230
+ expect(ids(event.userIds)).toEqual(["user-a", "user-b", "user-c"]);
231
+ expect(event.reason).toBe(OnCallShiftChangeReason.LayerUserChanged);
232
+ expect(event.occurredAt).toBeInstanceOf(Date);
233
+ });
234
+
235
+ test("skipVersionBump skips the bump but still purges and notifies", async () => {
236
+ jest
237
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
238
+ .mockResolvedValue([] as never);
239
+
240
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
241
+ scheduleIds: [SCHEDULE_1],
242
+ projectId: PROJECT_ID,
243
+ userIds: [USER_A],
244
+ reason: OnCallShiftChangeReason.ScheduleDeleted,
245
+ skipVersionBump: true,
246
+ });
247
+
248
+ expect(bump).not.toHaveBeenCalled();
249
+ expect(purgeSchedule).toHaveBeenCalledWith("schedule-1");
250
+ expect(notify).toHaveBeenCalledTimes(1);
251
+ expect(lastCallArg(notify).reason).toBe(
252
+ OnCallShiftChangeReason.ScheduleDeleted,
253
+ );
254
+ });
255
+
256
+ test("with no schedules: no bump, no schedule purge, but user purge and a notify for the users", async () => {
257
+ const findBy: any = jest.spyOn(
258
+ OnCallDutyPolicyScheduleLayerUserService,
259
+ "findBy",
260
+ );
261
+
262
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
263
+ scheduleIds: [],
264
+ projectId: PROJECT_ID,
265
+ userIds: [USER_A, USER_B],
266
+ reason: OnCallShiftChangeReason.OverrideChanged,
267
+ });
268
+
269
+ expect(bump).not.toHaveBeenCalled();
270
+ expect(purgeSchedule).not.toHaveBeenCalled();
271
+ expect(findBy).not.toHaveBeenCalled();
272
+ expect(purgeUser).toHaveBeenCalledTimes(2);
273
+ expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a", "user-b"]);
274
+ expect(lastCallArg(notify).scheduleIds).toEqual([]);
275
+ });
276
+
277
+ test("without a project id the user purge is skipped and the event carries null", async () => {
278
+ jest
279
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
280
+ .mockResolvedValue([] as never);
281
+
282
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
283
+ scheduleIds: [SCHEDULE_1],
284
+ userIds: [USER_A],
285
+ reason: OnCallShiftChangeReason.LayerChanged,
286
+ });
287
+
288
+ expect(purgeUser).not.toHaveBeenCalled();
289
+ expect(lastCallArg(notify).projectId).toBeNull();
290
+ expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a"]);
291
+ });
292
+
293
+ test("a failing member lookup still notifies with the explicit users", async () => {
294
+ jest
295
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
296
+ .mockRejectedValue(new Error("db down"));
297
+
298
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
299
+ scheduleIds: [SCHEDULE_1],
300
+ projectId: PROJECT_ID,
301
+ userIds: [USER_A],
302
+ reason: OnCallShiftChangeReason.LayerChanged,
303
+ });
304
+
305
+ expect(notify).toHaveBeenCalledTimes(1);
306
+ expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a"]);
307
+ expect(logger.error).toHaveBeenCalled();
308
+ });
309
+
310
+ test("failing purges never throw and do not stop the notification", async () => {
311
+ purgeSchedule.mockRejectedValue(new Error("redis down"));
312
+ purgeUser.mockRejectedValue(new Error("redis down"));
313
+ jest
314
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
315
+ .mockResolvedValue([] as never);
316
+
317
+ await expect(
318
+ OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
319
+ scheduleIds: [SCHEDULE_1],
320
+ projectId: PROJECT_ID,
321
+ userIds: [USER_A],
322
+ reason: OnCallShiftChangeReason.LayerChanged,
323
+ }),
324
+ ).resolves.toBeUndefined();
325
+
326
+ expect(notify).toHaveBeenCalledTimes(1);
327
+ });
328
+
329
+ test("a throwing bump never propagates", async () => {
330
+ bump.mockRejectedValue(new Error("unexpected"));
331
+ jest
332
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
333
+ .mockResolvedValue([] as never);
334
+
335
+ await expect(
336
+ OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
337
+ scheduleIds: [SCHEDULE_1],
338
+ projectId: PROJECT_ID,
339
+ reason: OnCallShiftChangeReason.LayerChanged,
340
+ }),
341
+ ).resolves.toBeUndefined();
342
+ });
343
+
344
+ test("delivers the event to a REAL registered listener without awaiting it", async () => {
345
+ notify.mockRestore();
346
+ OnCallShiftChangeListeners.clear();
347
+
348
+ let resolveListener: () => void = (): void => {
349
+ return undefined;
350
+ };
351
+ const received: Array<OnCallShiftChangeEvent> = [];
352
+
353
+ OnCallShiftChangeListeners.register(
354
+ (event: OnCallShiftChangeEvent): Promise<void> => {
355
+ received.push(event);
356
+ return new Promise<void>((resolve: () => void) => {
357
+ resolveListener = resolve;
358
+ });
359
+ },
360
+ "slow-listener",
361
+ );
362
+
363
+ jest
364
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
365
+ .mockResolvedValue([] as never);
366
+
367
+ // Resolves although the listener has not: delivery is in the background.
368
+ await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
369
+ scheduleIds: [SCHEDULE_1],
370
+ projectId: PROJECT_ID,
371
+ reason: OnCallShiftChangeReason.LayerChanged,
372
+ });
373
+
374
+ expect(received).toHaveLength(1);
375
+ expect(ids(received[0]!.scheduleIds)).toEqual(["schedule-1"]);
376
+
377
+ resolveListener();
378
+ OnCallShiftChangeListeners.clear();
379
+ });
380
+ });
381
+
382
+ describe("getScheduleIdsForUsersInProject", () => {
383
+ beforeEach(silenceLogger);
384
+ afterEach(() => {
385
+ jest.restoreAllMocks();
386
+ });
387
+
388
+ test("returns the distinct schedules of the users' layer-user rows", async () => {
389
+ const findBy: any = jest
390
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
391
+ .mockResolvedValue([
392
+ { onCallDutyPolicyScheduleId: SCHEDULE_1 },
393
+ { onCallDutyPolicyScheduleId: SCHEDULE_2 },
394
+ { onCallDutyPolicyScheduleId: new ObjectID("schedule-1") },
395
+ {},
396
+ ] as never);
397
+
398
+ const result: Array<ObjectID> =
399
+ await OnCallDutyPolicyScheduleService.getScheduleIdsForUsersInProject({
400
+ projectId: PROJECT_ID,
401
+ userIds: [USER_A, USER_B, USER_A],
402
+ });
403
+
404
+ expect(ids(result)).toEqual(["schedule-1", "schedule-2"]);
405
+ expect(findBy).toHaveBeenCalledTimes(1);
406
+ const query: any = findBy.mock.calls[0]![0].query;
407
+ expect(query.projectId).toBe(PROJECT_ID);
408
+ expect(query.userId).toBeDefined();
409
+ expect(findBy.mock.calls[0]![0].props.isRoot).toBe(true);
410
+ });
411
+
412
+ test("does not query for an empty user list", async () => {
413
+ const findBy: any = jest.spyOn(
414
+ OnCallDutyPolicyScheduleLayerUserService,
415
+ "findBy",
416
+ );
417
+
418
+ const result: Array<ObjectID> =
419
+ await OnCallDutyPolicyScheduleService.getScheduleIdsForUsersInProject({
420
+ projectId: PROJECT_ID,
421
+ userIds: [],
422
+ });
423
+
424
+ expect(result).toEqual([]);
425
+ expect(findBy).not.toHaveBeenCalled();
426
+ });
427
+ });
428
+
429
+ describe("the roster refresh never bumps shiftConfigVersion", () => {
430
+ beforeEach(silenceLogger);
431
+ afterEach(() => {
432
+ jest.restoreAllMocks();
433
+ });
434
+
435
+ test("refreshCurrentUserIdAndHandoffTimeInSchedule persists the roster without touching the version or the listeners", async () => {
436
+ jest.spyOn(Semaphore, "lock").mockRejectedValue(new Error("no redis"));
437
+ jest.spyOn(Semaphore, "release").mockResolvedValue(undefined as never);
438
+
439
+ jest
440
+ .spyOn(OnCallDutyPolicyScheduleService, "findOneById")
441
+ .mockResolvedValue({
442
+ currentUserIdOnRoster: USER_A,
443
+ rosterHandoffAt: null,
444
+ nextUserIdOnRoster: null,
445
+ rosterNextHandoffAt: null,
446
+ rosterStartAt: null,
447
+ rosterNextStartAt: null,
448
+ } as never);
449
+
450
+ jest
451
+ .spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findBy")
452
+ .mockResolvedValue([] as never);
453
+
454
+ jest
455
+ .spyOn(
456
+ OnCallDutyPolicyScheduleService,
457
+ "getCurrrentUserIdAndHandoffTimeInSchedule",
458
+ )
459
+ .mockResolvedValue({
460
+ currentUserId: USER_B,
461
+ handOffTimeAt: new Date("2026-09-01T09:00:00Z"),
462
+ nextUserId: USER_A,
463
+ nextHandOffTimeAt: new Date("2026-09-02T09:00:00Z"),
464
+ rosterStartAt: new Date("2026-08-31T09:00:00Z"),
465
+ nextRosterStartAt: new Date("2026-09-01T09:00:00Z"),
466
+ });
467
+
468
+ const updateOneById: any = jest
469
+ .spyOn(OnCallDutyPolicyScheduleService, "updateOneById")
470
+ .mockResolvedValue(undefined as never);
471
+
472
+ const bump: any = jest
473
+ .spyOn(OnCallDutyPolicyScheduleService, "bumpShiftConfigVersion")
474
+ .mockResolvedValue(undefined);
475
+ const atomic: any = jest
476
+ .spyOn(
477
+ OnCallDutyPolicyScheduleService,
478
+ "atomicAddToColumnsByIdWithoutHooks",
479
+ )
480
+ .mockResolvedValue(undefined);
481
+ const propagate: any = spyPropagate();
482
+ const notify: any = jest
483
+ .spyOn(OnCallShiftChangeListeners, "notify")
484
+ .mockResolvedValue(undefined);
485
+
486
+ await OnCallDutyPolicyScheduleService.refreshCurrentUserIdAndHandoffTimeInSchedule(
487
+ SCHEDULE_1,
488
+ );
489
+
490
+ // The roster was persisted through the hook-free path...
491
+ expect(updateOneById).toHaveBeenCalledTimes(1);
492
+ expect(updateOneById.mock.calls[0]![0].props).toEqual({
493
+ isRoot: true,
494
+ ignoreHooks: true,
495
+ });
496
+ expect(
497
+ updateOneById.mock.calls[0]![0].data.currentUserIdOnRoster.toString(),
498
+ ).toBe("user-b");
499
+
500
+ // ...and nothing that a calendar client would see as an edit happened.
501
+ expect(bump).not.toHaveBeenCalled();
502
+ expect(atomic).not.toHaveBeenCalled();
503
+ expect(propagate).not.toHaveBeenCalled();
504
+ expect(notify).not.toHaveBeenCalled();
505
+ });
506
+
507
+ test("onUpdateSuccess ignores roster-column writes and only reacts to name / timezone", async () => {
508
+ const propagate: any = spyPropagate();
509
+ const findBy: any = jest
510
+ .spyOn(OnCallDutyPolicyScheduleService, "findBy")
511
+ .mockResolvedValue([{ id: SCHEDULE_1, projectId: PROJECT_ID }] as never);
512
+
513
+ await (OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
514
+ {
515
+ updateBy: {
516
+ data: { currentUserIdOnRoster: USER_A, rosterHandoffAt: new Date() },
517
+ query: { _id: SCHEDULE_1 },
518
+ props: { isRoot: true },
519
+ },
520
+ carryForward: null,
521
+ },
522
+ [SCHEDULE_1],
523
+ );
524
+
525
+ expect(findBy).not.toHaveBeenCalled();
526
+ expect(propagate).not.toHaveBeenCalled();
527
+ });
528
+ });
529
+
530
+ describe("hook wiring: OnCallDutyPolicyScheduleService (rename / timezone / delete)", () => {
531
+ beforeEach(silenceLogger);
532
+ afterEach(() => {
533
+ jest.restoreAllMocks();
534
+ });
535
+
536
+ test("a rename bumps every updated schedule, grouped by project", async () => {
537
+ const propagate: any = spyPropagate();
538
+ jest.spyOn(OnCallDutyPolicyScheduleService, "findBy").mockResolvedValue([
539
+ { id: SCHEDULE_1, projectId: PROJECT_ID },
540
+ { id: SCHEDULE_2, projectId: new ObjectID("project-2") },
541
+ ] as never);
542
+
543
+ const onUpdate: any = {
544
+ updateBy: {
545
+ data: { name: "Renamed" },
546
+ query: { _id: SCHEDULE_1 },
547
+ props: {},
548
+ },
549
+ carryForward: null,
550
+ };
551
+
552
+ const returned: any = await (
553
+ OnCallDutyPolicyScheduleService as any
554
+ ).onUpdateSuccess(onUpdate, [SCHEDULE_1, SCHEDULE_2]);
555
+
556
+ expect(returned).toBe(onUpdate);
557
+ expect(propagate).toHaveBeenCalledTimes(2);
558
+
559
+ const byProject: Record<string, Array<string>> = {};
560
+ for (const call of propagate.mock.calls) {
561
+ const arg: any = call[0];
562
+ byProject[arg.projectId.toString()] = ids(arg.scheduleIds);
563
+ expect(arg.reason).toBe(OnCallShiftChangeReason.ScheduleChanged);
564
+ }
565
+ expect(byProject).toEqual({
566
+ "project-1": ["schedule-1"],
567
+ "project-2": ["schedule-2"],
568
+ });
569
+ });
570
+
571
+ test("a timezone change bumps as well", async () => {
572
+ const propagate: any = spyPropagate();
573
+ jest
574
+ .spyOn(OnCallDutyPolicyScheduleService, "findBy")
575
+ .mockResolvedValue([{ id: SCHEDULE_1, projectId: PROJECT_ID }] as never);
576
+
577
+ await (OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
578
+ {
579
+ updateBy: {
580
+ data: { timezone: "Europe/Stockholm" },
581
+ query: {},
582
+ props: {},
583
+ },
584
+ carryForward: null,
585
+ },
586
+ [SCHEDULE_1],
587
+ );
588
+
589
+ expect(propagate).toHaveBeenCalledTimes(1);
590
+ });
591
+
592
+ test("a failing lookup in onUpdateSuccess is logged, not thrown", async () => {
593
+ jest
594
+ .spyOn(OnCallDutyPolicyScheduleService, "findBy")
595
+ .mockRejectedValue(new Error("db down"));
596
+
597
+ await expect(
598
+ (OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
599
+ {
600
+ updateBy: { data: { name: "x" }, query: {}, props: {} },
601
+ carryForward: null,
602
+ },
603
+ [SCHEDULE_1],
604
+ ),
605
+ ).resolves.toBeDefined();
606
+ expect(logger.error).toHaveBeenCalled();
607
+ });
608
+
609
+ test("onBeforeDelete captures each schedule's members; onDeleteSuccess purges without a version bump", async () => {
610
+ jest.spyOn(OnCallDutyPolicyScheduleService, "findBy").mockResolvedValue([
611
+ { id: SCHEDULE_1, _id: SCHEDULE_1, projectId: PROJECT_ID },
612
+ { id: SCHEDULE_2, _id: SCHEDULE_2, projectId: PROJECT_ID },
613
+ ] as never);
614
+ jest
615
+ .spyOn(OnCallDutyPolicyTimeLogService, "endTimeForSchedule")
616
+ .mockResolvedValue(undefined as never);
617
+ jest
618
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
619
+ .mockResolvedValue([
620
+ { userId: USER_A, onCallDutyPolicyScheduleId: SCHEDULE_1 },
621
+ { userId: USER_B, onCallDutyPolicyScheduleId: SCHEDULE_1 },
622
+ { userId: USER_A, onCallDutyPolicyScheduleId: SCHEDULE_1 },
623
+ { userId: USER_C, onCallDutyPolicyScheduleId: SCHEDULE_2 },
624
+ ] as never);
625
+
626
+ const onDelete: any = await (
627
+ OnCallDutyPolicyScheduleService as any
628
+ ).onBeforeDelete({
629
+ query: { projectId: PROJECT_ID },
630
+ props: { isRoot: true },
631
+ });
632
+
633
+ const deleted: Array<any> = onDelete.carryForward.deletedSchedules;
634
+ expect(deleted).toHaveLength(2);
635
+ expect(deleted[0].scheduleId.toString()).toBe("schedule-1");
636
+ expect(ids(deleted[0].userIds)).toEqual(["user-a", "user-b"]);
637
+ expect(deleted[1].scheduleId.toString()).toBe("schedule-2");
638
+ expect(ids(deleted[1].userIds)).toEqual(["user-c"]);
639
+
640
+ const propagate: any = spyPropagate();
641
+
642
+ await (OnCallDutyPolicyScheduleService as any).onDeleteSuccess(onDelete, [
643
+ SCHEDULE_1,
644
+ SCHEDULE_2,
645
+ ]);
646
+
647
+ expect(propagate).toHaveBeenCalledTimes(2);
648
+ for (const call of propagate.mock.calls) {
649
+ const arg: any = call[0];
650
+ expect(arg.skipVersionBump).toBe(true);
651
+ expect(arg.reason).toBe(OnCallShiftChangeReason.ScheduleDeleted);
652
+ expect(arg.projectId).toBe(PROJECT_ID);
653
+ }
654
+ expect(ids(propagate.mock.calls[0]![0].userIds)).toEqual([
655
+ "user-a",
656
+ "user-b",
657
+ ]);
658
+ });
659
+
660
+ test("onBeforeDelete stays best-effort when the member lookup fails", async () => {
661
+ jest
662
+ .spyOn(OnCallDutyPolicyScheduleService, "findBy")
663
+ .mockResolvedValue([
664
+ { id: SCHEDULE_1, _id: SCHEDULE_1, projectId: PROJECT_ID },
665
+ ] as never);
666
+ jest
667
+ .spyOn(OnCallDutyPolicyTimeLogService, "endTimeForSchedule")
668
+ .mockResolvedValue(undefined as never);
669
+ jest
670
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
671
+ .mockRejectedValue(new Error("db down"));
672
+
673
+ const onDelete: any = await (
674
+ OnCallDutyPolicyScheduleService as any
675
+ ).onBeforeDelete({ query: { _id: SCHEDULE_1 }, props: { isRoot: true } });
676
+
677
+ expect(onDelete.carryForward.deletedSchedules).toEqual([]);
678
+ expect(onDelete.deleteBy.query._id).toBe(SCHEDULE_1);
679
+ });
680
+ });
681
+
682
+ describe("hook wiring: OnCallDutyPolicyScheduleLayerService", () => {
683
+ beforeEach(silenceLogger);
684
+ afterEach(() => {
685
+ jest.restoreAllMocks();
686
+ });
687
+
688
+ test("onCreateSuccess: roster refresh, then propagate LayerChanged", async () => {
689
+ jest
690
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
691
+ .mockResolvedValue({
692
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
693
+ projectId: PROJECT_ID,
694
+ } as never);
695
+ const refresh: any = spyRosterRefresh();
696
+ const propagate: any = spyPropagate();
697
+
698
+ await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
699
+ {},
700
+ { id: LAYER_1 },
701
+ );
702
+
703
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
704
+ expect(propagate).toHaveBeenCalledTimes(1);
705
+ expect(lastCallArg(propagate)).toEqual({
706
+ scheduleIds: [SCHEDULE_1],
707
+ projectId: PROJECT_ID,
708
+ reason: OnCallShiftChangeReason.LayerChanged,
709
+ });
710
+ expectCalledAfter(propagate, refresh);
711
+ });
712
+
713
+ test("onCreateSuccess: nothing happens when the row has no schedule", async () => {
714
+ jest
715
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
716
+ .mockResolvedValue({} as never);
717
+ const refresh: any = spyRosterRefresh();
718
+ const propagate: any = spyPropagate();
719
+
720
+ await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
721
+ {},
722
+ { id: LAYER_1 },
723
+ );
724
+
725
+ expect(refresh).not.toHaveBeenCalled();
726
+ expect(propagate).not.toHaveBeenCalled();
727
+ });
728
+
729
+ test("onUpdateSuccess propagates once per updated layer", async () => {
730
+ jest
731
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
732
+ .mockImplementation((args: any): Promise<any> => {
733
+ return Promise.resolve({
734
+ onCallDutyPolicyScheduleId:
735
+ args.id.toString() === "layer-1" ? SCHEDULE_1 : SCHEDULE_2,
736
+ projectId: PROJECT_ID,
737
+ });
738
+ });
739
+ const refresh: any = spyRosterRefresh();
740
+ const propagate: any = spyPropagate();
741
+
742
+ await (OnCallDutyPolicyScheduleLayerService as any).onUpdateSuccess(
743
+ { updateBy: { data: {}, query: {}, props: {} }, carryForward: null },
744
+ [LAYER_1, new ObjectID("layer-2")],
745
+ );
746
+
747
+ expect(refresh).toHaveBeenCalledTimes(2);
748
+ expect(propagate).toHaveBeenCalledTimes(2);
749
+ expect(
750
+ propagate.mock.calls.map((c: Array<any>) => {
751
+ return ids(c[0].scheduleIds)[0];
752
+ }),
753
+ ).toEqual(["schedule-1", "schedule-2"]);
754
+ });
755
+
756
+ test("onBeforeDelete captures projectId; onDeleteSuccess re-sequences, refreshes, then propagates", async () => {
757
+ jest
758
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findOneBy")
759
+ .mockResolvedValue({
760
+ order: 2,
761
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
762
+ projectId: PROJECT_ID,
763
+ } as never);
764
+ jest
765
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findBy")
766
+ .mockResolvedValue([] as never);
767
+
768
+ const deleteBy: any = { query: { _id: LAYER_1 }, props: {} };
769
+ const onDelete: any = await (
770
+ OnCallDutyPolicyScheduleLayerService as any
771
+ ).onBeforeDelete(deleteBy);
772
+
773
+ expect(onDelete.carryForward.projectId).toBe(PROJECT_ID);
774
+
775
+ const refresh: any = spyRosterRefresh();
776
+ const propagate: any = spyPropagate();
777
+
778
+ await (OnCallDutyPolicyScheduleLayerService as any).onDeleteSuccess(
779
+ onDelete,
780
+ [LAYER_1],
781
+ );
782
+
783
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
784
+ expect(lastCallArg(propagate)).toEqual({
785
+ scheduleIds: [SCHEDULE_1],
786
+ projectId: PROJECT_ID,
787
+ reason: OnCallShiftChangeReason.LayerChanged,
788
+ });
789
+ expectCalledAfter(propagate, refresh);
790
+ });
791
+
792
+ test("a root delete (no carried resource) neither refreshes nor propagates — unchanged behaviour", async () => {
793
+ const refresh: any = spyRosterRefresh();
794
+ const propagate: any = spyPropagate();
795
+
796
+ await (OnCallDutyPolicyScheduleLayerService as any).onDeleteSuccess(
797
+ { deleteBy: { query: {}, props: { isRoot: true } }, carryForward: null },
798
+ [LAYER_1],
799
+ );
800
+
801
+ expect(refresh).not.toHaveBeenCalled();
802
+ expect(propagate).not.toHaveBeenCalled();
803
+ });
804
+
805
+ /*
806
+ * Same contract as the layer-user hooks: the layer row is committed before
807
+ * the success hook runs, so a throwing roster refresh must not swallow the
808
+ * version bump and the cache purge.
809
+ */
810
+ test("a throwing roster refresh still propagates the layer change", async () => {
811
+ jest
812
+ .spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
813
+ .mockResolvedValue({
814
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
815
+ projectId: PROJECT_ID,
816
+ } as never);
817
+ const refresh: any = spyRosterRefresh();
818
+ refresh.mockRejectedValue(new Error("Schedule not found"));
819
+ const propagate: any = spyPropagate();
820
+
821
+ await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
822
+ {},
823
+ { id: LAYER_1 },
824
+ );
825
+
826
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
827
+ expect(lastCallArg(propagate)).toEqual({
828
+ scheduleIds: [SCHEDULE_1],
829
+ projectId: PROJECT_ID,
830
+ reason: OnCallShiftChangeReason.LayerChanged,
831
+ });
832
+ expect(logger.error).toHaveBeenCalled();
833
+ });
834
+ });
835
+
836
+ describe("hook wiring: OnCallDutyPolicyScheduleLayerUserService", () => {
837
+ beforeEach(silenceLogger);
838
+ afterEach(() => {
839
+ jest.restoreAllMocks();
840
+ });
841
+
842
+ test("onCreateSuccess names the added user, after the roster refresh", async () => {
843
+ jest
844
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
845
+ .mockResolvedValue({
846
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
847
+ projectId: PROJECT_ID,
848
+ userId: USER_A,
849
+ } as never);
850
+ const refresh: any = spyRosterRefresh();
851
+ const propagate: any = spyPropagate();
852
+
853
+ await (OnCallDutyPolicyScheduleLayerUserService as any).onCreateSuccess(
854
+ {},
855
+ { id: ROW_ID },
856
+ );
857
+
858
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
859
+ expect(lastCallArg(propagate)).toEqual({
860
+ scheduleIds: [SCHEDULE_1],
861
+ projectId: PROJECT_ID,
862
+ userIds: [USER_A],
863
+ reason: OnCallShiftChangeReason.LayerUserChanged,
864
+ });
865
+ expectCalledAfter(propagate, refresh);
866
+ });
867
+
868
+ test("onUpdateSuccess (re-order) propagates per row", async () => {
869
+ jest
870
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
871
+ .mockResolvedValue({
872
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
873
+ projectId: PROJECT_ID,
874
+ userId: USER_B,
875
+ } as never);
876
+ spyRosterRefresh();
877
+ const propagate: any = spyPropagate();
878
+
879
+ await (OnCallDutyPolicyScheduleLayerUserService as any).onUpdateSuccess(
880
+ { updateBy: { data: { order: 1 }, query: {}, props: {} } },
881
+ [ROW_ID],
882
+ );
883
+
884
+ expect(propagate).toHaveBeenCalledTimes(1);
885
+ expect(ids(lastCallArg(propagate).userIds)).toEqual(["user-b"]);
886
+ });
887
+
888
+ test("onBeforeDelete captures user + project; onDeleteSuccess propagates with the removed user", async () => {
889
+ jest
890
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneBy")
891
+ .mockResolvedValue({
892
+ order: 1,
893
+ onCallDutyPolicyScheduleLayerId: LAYER_1,
894
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
895
+ projectId: PROJECT_ID,
896
+ userId: USER_C,
897
+ } as never);
898
+ jest
899
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
900
+ .mockResolvedValue([] as never);
901
+
902
+ const onDelete: any = await (
903
+ OnCallDutyPolicyScheduleLayerUserService as any
904
+ ).onBeforeDelete({ query: { _id: ROW_ID }, props: {} });
905
+
906
+ expect(onDelete.carryForward.userId).toBe(USER_C);
907
+ expect(onDelete.carryForward.projectId).toBe(PROJECT_ID);
908
+
909
+ const refresh: any = spyRosterRefresh();
910
+ const propagate: any = spyPropagate();
911
+
912
+ await (OnCallDutyPolicyScheduleLayerUserService as any).onDeleteSuccess(
913
+ onDelete,
914
+ [ROW_ID],
915
+ );
916
+
917
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
918
+ expect(lastCallArg(propagate)).toEqual({
919
+ scheduleIds: [SCHEDULE_1],
920
+ projectId: PROJECT_ID,
921
+ userIds: [USER_C],
922
+ reason: OnCallShiftChangeReason.LayerUserChanged,
923
+ });
924
+ expectCalledAfter(propagate, refresh);
925
+ });
926
+
927
+ test("resequenceOrderInLayer renumbers 1..n and leaves already-correct rows alone", async () => {
928
+ jest
929
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
930
+ .mockResolvedValue([
931
+ { _id: new ObjectID("r1"), order: 1 },
932
+ { _id: new ObjectID("r2"), order: 3 },
933
+ { _id: new ObjectID("r3"), order: 7 },
934
+ ] as never);
935
+ const update: any = jest
936
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "updateOneBy")
937
+ .mockResolvedValue(undefined as never);
938
+
939
+ await OnCallDutyPolicyScheduleLayerUserService.resequenceOrderInLayer(
940
+ LAYER_1,
941
+ );
942
+
943
+ expect(update).toHaveBeenCalledTimes(2);
944
+ expect(update.mock.calls[0]![0].query._id.toString()).toBe("r2");
945
+ expect(update.mock.calls[0]![0].data).toEqual({ order: 2 });
946
+ expect(update.mock.calls[1]![0].query._id.toString()).toBe("r3");
947
+ expect(update.mock.calls[1]![0].data).toEqual({ order: 3 });
948
+ /*
949
+ * ignoreHooks: renumbering is bookkeeping, not a roster change. With the
950
+ * hooks on, every renumbered row would run a full onUpdateSuccess
951
+ * (semaphore-locked roster refresh + version bump + cache purge +
952
+ * listener pass) — N-1 redundant refreshes per layer — and a throw from
953
+ * one of them would abort the rest of the renumbering. The caller
954
+ * refreshes and propagates once per schedule itself.
955
+ */
956
+ expect(update.mock.calls[0]![0].props).toEqual({
957
+ isRoot: true,
958
+ ignoreHooks: true,
959
+ });
960
+ expect(update.mock.calls[1]![0].props).toEqual({
961
+ isRoot: true,
962
+ ignoreHooks: true,
963
+ });
964
+ });
965
+
966
+ /*
967
+ * The row is already committed when the success hooks run, so a throwing
968
+ * roster refresh must not stop the version bump and the cache purge:
969
+ * otherwise the feed caches keep serving the pre-edit roster for up to
970
+ * their TTL (an hour for schedule segments) and the reminder change pass
971
+ * for that edit never runs.
972
+ */
973
+ test("a throwing roster refresh still propagates: onCreateSuccess", async () => {
974
+ jest
975
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
976
+ .mockResolvedValue({
977
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
978
+ projectId: PROJECT_ID,
979
+ userId: USER_A,
980
+ } as never);
981
+ const refresh: any = spyRosterRefresh();
982
+ refresh.mockRejectedValue(new Error("Schedule not found"));
983
+ const propagate: any = spyPropagate();
984
+
985
+ await expect(
986
+ (OnCallDutyPolicyScheduleLayerUserService as any).onCreateSuccess(
987
+ {},
988
+ { id: ROW_ID },
989
+ ),
990
+ ).resolves.toBeDefined();
991
+
992
+ expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
993
+ expect(lastCallArg(propagate)).toEqual({
994
+ scheduleIds: [SCHEDULE_1],
995
+ projectId: PROJECT_ID,
996
+ userIds: [USER_A],
997
+ reason: OnCallShiftChangeReason.LayerUserChanged,
998
+ });
999
+ expect(logger.error).toHaveBeenCalled();
1000
+ });
1001
+
1002
+ test("a throwing roster refresh still propagates: onDeleteSuccess", async () => {
1003
+ jest
1004
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
1005
+ .mockResolvedValue([] as never);
1006
+ jest
1007
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "updateOneBy")
1008
+ .mockResolvedValue(undefined as never);
1009
+ const refresh: any = spyRosterRefresh();
1010
+ refresh.mockRejectedValue(new Error("redis down"));
1011
+ const propagate: any = spyPropagate();
1012
+
1013
+ await (OnCallDutyPolicyScheduleLayerUserService as any).onDeleteSuccess(
1014
+ {
1015
+ deleteBy: { query: { _id: ROW_ID }, props: {} },
1016
+ carryForward: {
1017
+ order: 1,
1018
+ onCallDutyPolicyScheduleLayerId: LAYER_1,
1019
+ onCallDutyPolicyScheduleId: SCHEDULE_1,
1020
+ projectId: PROJECT_ID,
1021
+ userId: USER_A,
1022
+ },
1023
+ },
1024
+ [ROW_ID],
1025
+ );
1026
+
1027
+ expect(lastCallArg(propagate)).toEqual({
1028
+ scheduleIds: [SCHEDULE_1],
1029
+ projectId: PROJECT_ID,
1030
+ userIds: [USER_A],
1031
+ reason: OnCallShiftChangeReason.LayerUserChanged,
1032
+ });
1033
+ });
1034
+ });
1035
+
1036
+ describe("hook wiring: OnCallDutyPolicyUserOverrideService", () => {
1037
+ beforeEach(silenceLogger);
1038
+ afterEach(() => {
1039
+ jest.restoreAllMocks();
1040
+ });
1041
+
1042
+ function stubScheduleLookup(scheduleIds: Array<ObjectID>): any {
1043
+ return jest
1044
+ .spyOn(OnCallDutyPolicyScheduleService, "getScheduleIdsForUsersInProject")
1045
+ .mockResolvedValue(scheduleIds);
1046
+ }
1047
+
1048
+ test("onCreateSuccess: schedules of the overridden user are bumped; both users are named", async () => {
1049
+ const refreshRosters: any = jest
1050
+ .spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
1051
+ .mockResolvedValue(undefined);
1052
+ const lookup: any = stubScheduleLookup([SCHEDULE_1, SCHEDULE_2]);
1053
+ const propagate: any = spyPropagate();
1054
+
1055
+ await (OnCallDutyPolicyUserOverrideService as any).onCreateSuccess(
1056
+ {},
1057
+ {
1058
+ projectId: PROJECT_ID,
1059
+ overrideUserId: USER_A,
1060
+ routeAlertsToUserId: USER_C,
1061
+ // no onCallDutyPolicyId: global override, no policy feed item
1062
+ },
1063
+ );
1064
+
1065
+ expect(refreshRosters).toHaveBeenCalledTimes(1);
1066
+ expect(lookup).toHaveBeenCalledTimes(1);
1067
+ expect(ids(lookup.mock.calls[0]![0].userIds)).toEqual(["user-a"]);
1068
+
1069
+ expect(propagate).toHaveBeenCalledTimes(1);
1070
+ const arg: any = lastCallArg(propagate);
1071
+ expect(ids(arg.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
1072
+ expect(arg.projectId).toBe(PROJECT_ID);
1073
+ expect(ids(arg.userIds)).toEqual(["user-a", "user-c"]);
1074
+ expect(arg.reason).toBe(OnCallShiftChangeReason.OverrideChanged);
1075
+ expectCalledAfter(propagate, refreshRosters);
1076
+ });
1077
+
1078
+ test("onBeforeUpdate captures the substitute as well", async () => {
1079
+ jest
1080
+ .spyOn(OnCallDutyPolicyUserOverrideService, "findBy")
1081
+ .mockResolvedValue([
1082
+ {
1083
+ projectId: PROJECT_ID,
1084
+ overrideUserId: USER_A,
1085
+ routeAlertsToUserId: USER_C,
1086
+ },
1087
+ ] as never);
1088
+
1089
+ const onUpdate: any = await (
1090
+ OnCallDutyPolicyUserOverrideService as any
1091
+ ).onBeforeUpdate({ query: { _id: ROW_ID }, data: {}, props: {} });
1092
+
1093
+ expect(onUpdate.carryForward).toHaveLength(1);
1094
+ expect(onUpdate.carryForward[0].overrideUserId).toBe(USER_A);
1095
+ expect(onUpdate.carryForward[0].routeAlertsToUserId).toBe(USER_C);
1096
+ });
1097
+
1098
+ test("onUpdateSuccess: OLD and NEW overridden users' schedules, all four users named, one propagate per project", async () => {
1099
+ jest
1100
+ .spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
1101
+ .mockResolvedValue(undefined);
1102
+ jest
1103
+ .spyOn(OnCallDutyPolicyUserOverrideService, "findOneById")
1104
+ .mockResolvedValue({
1105
+ projectId: PROJECT_ID,
1106
+ overrideUserId: USER_B,
1107
+ routeAlertsToUserId: USER_D,
1108
+ } as never);
1109
+ const lookup: any = stubScheduleLookup([SCHEDULE_1]);
1110
+ const propagate: any = spyPropagate();
1111
+
1112
+ await (OnCallDutyPolicyUserOverrideService as any).onUpdateSuccess(
1113
+ {
1114
+ updateBy: { query: { _id: ROW_ID }, data: {}, props: {} },
1115
+ carryForward: [
1116
+ {
1117
+ projectId: PROJECT_ID,
1118
+ overrideUserId: USER_A,
1119
+ routeAlertsToUserId: USER_C,
1120
+ },
1121
+ ],
1122
+ },
1123
+ [ROW_ID],
1124
+ );
1125
+
1126
+ expect(lookup).toHaveBeenCalledTimes(1);
1127
+ expect(ids(lookup.mock.calls[0]![0].userIds)).toEqual(["user-a", "user-b"]);
1128
+
1129
+ expect(propagate).toHaveBeenCalledTimes(1);
1130
+ const arg: any = lastCallArg(propagate);
1131
+ expect(ids(arg.scheduleIds)).toEqual(["schedule-1"]);
1132
+ expect(ids(arg.userIds).sort()).toEqual([
1133
+ "user-a",
1134
+ "user-b",
1135
+ "user-c",
1136
+ "user-d",
1137
+ ]);
1138
+ });
1139
+
1140
+ test("onDeleteSuccess propagates for the deleted overrides", async () => {
1141
+ jest
1142
+ .spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
1143
+ .mockResolvedValue(undefined);
1144
+ stubScheduleLookup([SCHEDULE_2]);
1145
+ const propagate: any = spyPropagate();
1146
+
1147
+ await (OnCallDutyPolicyUserOverrideService as any).onDeleteSuccess(
1148
+ {
1149
+ deleteBy: { query: {}, props: {} },
1150
+ carryForward: [
1151
+ {
1152
+ projectId: PROJECT_ID,
1153
+ overrideUserId: USER_A,
1154
+ routeAlertsToUserId: USER_C,
1155
+ },
1156
+ ],
1157
+ },
1158
+ [ROW_ID],
1159
+ );
1160
+
1161
+ expect(propagate).toHaveBeenCalledTimes(1);
1162
+ expect(ids(lastCallArg(propagate).scheduleIds)).toEqual(["schedule-2"]);
1163
+ expect(lastCallArg(propagate).reason).toBe(
1164
+ OnCallShiftChangeReason.OverrideChanged,
1165
+ );
1166
+ });
1167
+
1168
+ test("a failing schedule lookup is logged and never thrown", async () => {
1169
+ jest
1170
+ .spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
1171
+ .mockResolvedValue(undefined);
1172
+ jest
1173
+ .spyOn(OnCallDutyPolicyScheduleService, "getScheduleIdsForUsersInProject")
1174
+ .mockRejectedValue(new Error("db down"));
1175
+ const propagate: any = spyPropagate();
1176
+
1177
+ await expect(
1178
+ (OnCallDutyPolicyUserOverrideService as any).onDeleteSuccess(
1179
+ {
1180
+ deleteBy: { query: {}, props: {} },
1181
+ carryForward: [{ projectId: PROJECT_ID, overrideUserId: USER_A }],
1182
+ },
1183
+ [ROW_ID],
1184
+ ),
1185
+ ).resolves.toBeDefined();
1186
+
1187
+ expect(propagate).not.toHaveBeenCalled();
1188
+ expect(logger.error).toHaveBeenCalled();
1189
+ });
1190
+ });
1191
+
1192
+ describe("hook wiring: OnCallDutyPolicyEscalationRuleScheduleService", () => {
1193
+ beforeEach(() => {
1194
+ silenceLogger();
1195
+ jest
1196
+ .spyOn(OnCallDutyPolicyScheduleService, "getCurrentUserIdInSchedule")
1197
+ .mockResolvedValue(null);
1198
+ jest
1199
+ .spyOn(OnCallDutyPolicyFeedService, "createOnCallDutyPolicyFeedItem")
1200
+ .mockResolvedValue(undefined as never);
1201
+ jest
1202
+ .spyOn(OnCallDutyPolicyTimeLogService, "startTimeLogForUser")
1203
+ .mockResolvedValue(undefined as never);
1204
+ jest
1205
+ .spyOn(OnCallDutyPolicyTimeLogService, "endTimeLogForUser")
1206
+ .mockResolvedValue(undefined as never);
1207
+ });
1208
+
1209
+ afterEach(() => {
1210
+ jest.restoreAllMocks();
1211
+ });
1212
+
1213
+ function linkRow(scheduleId: ObjectID): any {
1214
+ return {
1215
+ id: ROW_ID,
1216
+ projectId: PROJECT_ID,
1217
+ onCallDutyPolicyScheduleId: scheduleId,
1218
+ onCallDutyPolicySchedule: { name: "Primary schedule" },
1219
+ onCallDutyPolicyEscalationRule: { name: "Rule 1", id: RULE_ID, order: 1 },
1220
+ onCallDutyPolicy: { name: "Payments", id: POLICY_ID },
1221
+ createdByUserId: null,
1222
+ };
1223
+ }
1224
+
1225
+ test("attaching propagates PolicyAttachmentChanged after the roster refresh", async () => {
1226
+ jest
1227
+ .spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findOneById")
1228
+ .mockResolvedValue(linkRow(SCHEDULE_1));
1229
+ const refresh: any = spyRosterRefresh();
1230
+ const propagate: any = spyPropagate();
1231
+
1232
+ await (
1233
+ OnCallDutyPolicyEscalationRuleScheduleService as any
1234
+ ).onCreateSuccess({}, { id: ROW_ID });
1235
+
1236
+ expect(lastCallArg(propagate)).toEqual({
1237
+ scheduleIds: [SCHEDULE_1],
1238
+ projectId: PROJECT_ID,
1239
+ reason: OnCallShiftChangeReason.PolicyAttachmentChanged,
1240
+ });
1241
+ expectCalledAfter(propagate, refresh);
1242
+ });
1243
+
1244
+ test("detaching propagates once per distinct schedule", async () => {
1245
+ spyRosterRefresh();
1246
+ const propagate: any = spyPropagate();
1247
+
1248
+ await (
1249
+ OnCallDutyPolicyEscalationRuleScheduleService as any
1250
+ ).onDeleteSuccess(
1251
+ {
1252
+ carryForward: {
1253
+ deletedItems: [
1254
+ linkRow(SCHEDULE_1),
1255
+ linkRow(SCHEDULE_1),
1256
+ linkRow(SCHEDULE_2),
1257
+ ],
1258
+ },
1259
+ },
1260
+ [ROW_ID],
1261
+ );
1262
+
1263
+ expect(propagate).toHaveBeenCalledTimes(2);
1264
+ expect(
1265
+ propagate.mock.calls.map((c: Array<any>) => {
1266
+ return ids(c[0].scheduleIds)[0];
1267
+ }),
1268
+ ).toEqual(["schedule-1", "schedule-2"]);
1269
+ });
1270
+ });