@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,3088 @@
1
+ import OnCallShiftReminderRunner, {
2
+ SHIFT_REMINDER_JOB_NAME,
3
+ SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE,
4
+ SHIFT_REMINDER_LOG_RETENTION_DAYS,
5
+ SHIFT_REMINDER_MAX_LOOKBACK_MINUTES,
6
+ SHIFT_REMINDER_RECLAIM_AFTER_MINUTES,
7
+ SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE,
8
+ SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS,
9
+ SHIFT_REMINDER_WINDOW_PADDING_MINUTES,
10
+ ShiftReminderChangePassStats,
11
+ ShiftReminderMessage,
12
+ ShiftReminderOutcome,
13
+ ShiftReminderRetentionStats,
14
+ ShiftReminderSweepStats,
15
+ } from "../../../../Server/Utils/OnCall/OnCallShiftReminderRunner";
16
+ import OnCallShiftChangeListeners, {
17
+ OnCallShiftChangeEvent,
18
+ OnCallShiftChangeReason,
19
+ } from "../../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
20
+ import OnCallShiftMaterializer from "../../../../Server/Utils/OnCall/OnCallShiftMaterializer";
21
+ import UserOnCallShiftReminderLogService from "../../../../Server/Services/UserOnCallShiftReminderLogService";
22
+ import UserNotificationSettingService from "../../../../Server/Services/UserNotificationSettingService";
23
+ import UserService from "../../../../Server/Services/UserService";
24
+ import Semaphore from "../../../../Server/Infrastructure/Semaphore";
25
+ import { UserOnCallShiftReminderLogKind } from "../../../../Models/DatabaseModels/UserOnCallShiftReminderLog";
26
+ import OneUptimeDate from "../../../../Types/Date";
27
+ import EmailTemplateType from "../../../../Types/Email/EmailTemplateType";
28
+ import NotificationSettingEventType from "../../../../Types/NotificationSetting/NotificationSettingEventType";
29
+ import ObjectID from "../../../../Types/ObjectID";
30
+ import { MaterializedShift } from "../../../../Types/OnCallDutyPolicy/MaterializedShift";
31
+ import { WhatsAppMessagePayload } from "../../../../Types/WhatsApp/WhatsAppMessage";
32
+ import { WhatsAppTemplateIds } from "../../../../Types/WhatsApp/WhatsAppTemplates";
33
+ import Timezone from "../../../../Types/Timezone";
34
+ import {
35
+ DEFAULT_POLICY,
36
+ at,
37
+ shift,
38
+ } from "../../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
39
+ import {
40
+ FakeLedgerRow,
41
+ HARNESS_DASHBOARD_URL,
42
+ MetricRecord,
43
+ ReminderHarness,
44
+ SentNotification,
45
+ emptyHarness,
46
+ grantSettings,
47
+ installReminderHarness,
48
+ ledgerRowsOfKind,
49
+ scheduleInfo,
50
+ seedLedgerRow,
51
+ watermarkOf,
52
+ } from "./OnCallShiftReminderTestHarness";
53
+ import {
54
+ afterEach,
55
+ beforeEach,
56
+ describe,
57
+ expect,
58
+ jest,
59
+ test,
60
+ } from "@jest/globals";
61
+
62
+ /*
63
+ * OnCallShiftReminderRunner — "your on-call shift on Payments starts in 1
64
+ * hour", the change notices that keep it honest, and the ledger that makes
65
+ * every one of them fire exactly once.
66
+ *
67
+ * The real runner runs here against in-memory stand-ins (see the harness):
68
+ * the reminder lead times, the UNIQUE-indexed ledger, the materializer, the
69
+ * notification settings, the watermark cache and the sweep lock. What is
70
+ * pinned, in the order the spec lists it:
71
+ *
72
+ * - the watermark: a skipped tick is caught up by the next one; a missing
73
+ * or ancient watermark falls back to a 30-minute lookback, never more;
74
+ * - the lateness cap: never "starts in 15 minutes" after it started;
75
+ * - claim -> send -> stamp: a thrown send releases the claim so the next
76
+ * tick retries; a unique violation from another replica is a skip; a
77
+ * stale claim is re-claimed with a conditional update after 10 minutes;
78
+ * - the ledger key is the MINUTE, so a start moved by seconds is the same
79
+ * reminder;
80
+ * - the change pass: one catch-up for a shift a user now holds inside a
81
+ * lead, one "reassigned" notice for a shift they were reminded about and
82
+ * no longer hold — and nothing twice;
83
+ * - zero-policy schedules never remind (they cannot page);
84
+ * - a missing UserNotificationSetting row is warned about once per day;
85
+ * - every message is formatted in the recipient's own timezone;
86
+ * - ledger retention by SHIFT start.
87
+ */
88
+
89
+ const PROJECT: ObjectID = new ObjectID("project-1");
90
+ const PROJECT_2: ObjectID = new ObjectID("project-2");
91
+ const USER_A: ObjectID = new ObjectID("user-a");
92
+ const USER_B: ObjectID = new ObjectID("user-b");
93
+ const USER_C: ObjectID = new ObjectID("user-c");
94
+ const SCHEDULE: string = "schedule-1";
95
+ const SCHEDULE_2: string = "schedule-2";
96
+
97
+ // Thursday 3 September 2026, 17:00 in Berlin (CEST): the tick.
98
+ const NOW: Date = at("2026-09-03T15:00:00Z");
99
+ // The shift under test starts one hour later — 18:00 Berlin, 14:00 New York.
100
+ const SHIFT_START: Date = at("2026-09-03T16:00:00Z");
101
+ const SHIFT_END: Date = at("2026-09-04T16:00:00Z");
102
+
103
+ const BERLIN_START_TEXT: string = "Thu 3 Sep 18:00 Europe/Berlin";
104
+ // New York is on EDT (UTC-4) in September.
105
+ const NEW_YORK_START_TEXT: string = "Thu 3 Sep 12:00 America/New_York";
106
+
107
+ function minutes(base: Date, delta: number): Date {
108
+ return OneUptimeDate.addRemoveMinutes(base, delta);
109
+ }
110
+
111
+ function baseHarness(): ReminderHarness {
112
+ const harness: ReminderHarness = emptyHarness();
113
+
114
+ harness.users = [
115
+ {
116
+ userId: USER_A.toString(),
117
+ userName: "Alice Andersson",
118
+ email: "alice@example.com",
119
+ timezone: "Europe/Berlin",
120
+ },
121
+ {
122
+ userId: USER_B.toString(),
123
+ userName: "Bob Brown",
124
+ email: "bob@example.com",
125
+ timezone: "America/New_York",
126
+ },
127
+ { userId: USER_C.toString(), userName: "Carol Chen" },
128
+ ];
129
+
130
+ harness.schedules.set(
131
+ SCHEDULE,
132
+ scheduleInfo({
133
+ scheduleId: SCHEDULE,
134
+ scheduleName: "Payments",
135
+ projectId: PROJECT.toString(),
136
+ scheduleTimezone: "Europe/Stockholm",
137
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
138
+ }),
139
+ );
140
+ harness.schedules.set(
141
+ SCHEDULE_2,
142
+ scheduleInfo({
143
+ scheduleId: SCHEDULE_2,
144
+ scheduleName: "Database",
145
+ projectId: PROJECT.toString(),
146
+ scheduleTimezone: "Europe/Stockholm",
147
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
148
+ }),
149
+ );
150
+
151
+ harness.scheduleProjects.set(SCHEDULE, PROJECT);
152
+ harness.scheduleProjects.set(SCHEDULE_2, PROJECT);
153
+
154
+ for (const userId of [USER_A, USER_B, USER_C]) {
155
+ grantSettings(harness, { userId, projectId: PROJECT });
156
+ grantSettings(harness, { userId, projectId: PROJECT_2 });
157
+ }
158
+
159
+ return harness;
160
+ }
161
+
162
+ function aliceShift(
163
+ overrides: Partial<MaterializedShift> = {},
164
+ ): MaterializedShift {
165
+ return shift({
166
+ scheduleId: SCHEDULE,
167
+ scheduleName: "Payments",
168
+ projectId: PROJECT.toString(),
169
+ userId: USER_A.toString(),
170
+ userName: "Alice Andersson",
171
+ start: SHIFT_START,
172
+ end: SHIFT_END,
173
+ scheduleTimezone: "Europe/Stockholm",
174
+ ...overrides,
175
+ });
176
+ }
177
+
178
+ function bobCoveringForAlice(
179
+ overrides: Partial<MaterializedShift> = {},
180
+ ): MaterializedShift {
181
+ return aliceShift({
182
+ userId: USER_B.toString(),
183
+ userName: "Bob Brown",
184
+ override: {
185
+ originalUserId: USER_A.toString(),
186
+ originalUserName: "Alice Andersson",
187
+ overrideStartsAt: SHIFT_START,
188
+ overrideEndsAt: SHIFT_END,
189
+ },
190
+ ...overrides,
191
+ });
192
+ }
193
+
194
+ function reminder(
195
+ userId: ObjectID,
196
+ minutesBeforeShift: number,
197
+ projectId: ObjectID = PROJECT,
198
+ ): { projectId: ObjectID; userId: ObjectID; minutesBeforeShift: number } {
199
+ return { projectId, userId, minutesBeforeShift };
200
+ }
201
+
202
+ function changeEvent(data: {
203
+ projectId?: ObjectID | null | undefined;
204
+ scheduleIds?: Array<string> | undefined;
205
+ userIds?: Array<ObjectID> | undefined;
206
+ reason?: OnCallShiftChangeReason | undefined;
207
+ }): OnCallShiftChangeEvent {
208
+ return OnCallShiftChangeListeners.buildEvent({
209
+ projectId: data.projectId === undefined ? PROJECT : data.projectId,
210
+ scheduleIds: (data.scheduleIds ?? [SCHEDULE]).map((id: string) => {
211
+ return new ObjectID(id);
212
+ }),
213
+ userIds: data.userIds ?? [],
214
+ reason: data.reason ?? OnCallShiftChangeReason.OverrideChanged,
215
+ occurredAt: NOW,
216
+ });
217
+ }
218
+
219
+ function outcomes(harness: ReminderHarness): Array<string> {
220
+ return harness.metrics.map((record: MetricRecord) => {
221
+ return String(record.attributes["oneuptime.oncall_shift_reminder.outcome"]);
222
+ });
223
+ }
224
+
225
+ describe("OnCallShiftReminderRunner", () => {
226
+ let harness: ReminderHarness;
227
+
228
+ beforeEach(() => {
229
+ harness = baseHarness();
230
+ installReminderHarness(harness);
231
+ OnCallShiftReminderRunner.resetMissingSettingsWarnings();
232
+ });
233
+
234
+ afterEach(() => {
235
+ jest.restoreAllMocks();
236
+ });
237
+
238
+ /*
239
+ * ---------------------------------------------------------------------
240
+ * Pure helpers
241
+ * ---------------------------------------------------------------------
242
+ */
243
+
244
+ describe("computeLookbackFrom", () => {
245
+ test("falls back to a 30-minute lookback when there is no watermark", () => {
246
+ expect(
247
+ OnCallShiftReminderRunner.computeLookbackFrom(NOW, null).toISOString(),
248
+ ).toBe(minutes(NOW, -SHIFT_REMINDER_MAX_LOOKBACK_MINUTES).toISOString());
249
+ });
250
+
251
+ test("uses the watermark when it is inside the lookback cap", () => {
252
+ const watermark: Date = minutes(NOW, -5);
253
+
254
+ expect(
255
+ OnCallShiftReminderRunner.computeLookbackFrom(
256
+ NOW,
257
+ watermark,
258
+ ).toISOString(),
259
+ ).toBe(watermark.toISOString());
260
+ });
261
+
262
+ test("caps an old watermark at 30 minutes back (a long outage never floods)", () => {
263
+ expect(
264
+ OnCallShiftReminderRunner.computeLookbackFrom(
265
+ NOW,
266
+ minutes(NOW, -120),
267
+ ).toISOString(),
268
+ ).toBe(minutes(NOW, -30).toISOString());
269
+ });
270
+
271
+ test("never returns a lookback in the future (replica clock skew)", () => {
272
+ expect(
273
+ OnCallShiftReminderRunner.computeLookbackFrom(
274
+ NOW,
275
+ minutes(NOW, 10),
276
+ ).toISOString(),
277
+ ).toBe(NOW.toISOString());
278
+ });
279
+ });
280
+
281
+ describe("isDue", () => {
282
+ const lookbackFrom: Date = minutes(NOW, -5);
283
+
284
+ test("a start whose lead instant falls inside (lookbackFrom, now] is due", () => {
285
+ // lead 60: due starts are in (now-5+60, now+60] = (now+55, now+60].
286
+ expect(
287
+ OnCallShiftReminderRunner.isDue({
288
+ start: minutes(NOW, 58),
289
+ lead: 60,
290
+ now: NOW,
291
+ lookbackFrom,
292
+ }),
293
+ ).toBe(true);
294
+ });
295
+
296
+ test("the upper bound (now + lead) is inclusive, the lower bound exclusive", () => {
297
+ expect(
298
+ OnCallShiftReminderRunner.isDue({
299
+ start: minutes(NOW, 60),
300
+ lead: 60,
301
+ now: NOW,
302
+ lookbackFrom,
303
+ }),
304
+ ).toBe(true);
305
+ expect(
306
+ OnCallShiftReminderRunner.isDue({
307
+ start: minutes(NOW, 55),
308
+ lead: 60,
309
+ now: NOW,
310
+ lookbackFrom,
311
+ }),
312
+ ).toBe(false);
313
+ });
314
+
315
+ test("a start beyond now + lead is not yet due; one before the window is already past", () => {
316
+ expect(
317
+ OnCallShiftReminderRunner.isDue({
318
+ start: minutes(NOW, 61),
319
+ lead: 60,
320
+ now: NOW,
321
+ lookbackFrom,
322
+ }),
323
+ ).toBe(false);
324
+ expect(
325
+ OnCallShiftReminderRunner.isDue({
326
+ start: minutes(NOW, 50),
327
+ lead: 60,
328
+ now: NOW,
329
+ lookbackFrom,
330
+ }),
331
+ ).toBe(false);
332
+ });
333
+ });
334
+
335
+ describe("describeMinutes", () => {
336
+ test.each([
337
+ [15, "15 minutes"],
338
+ [1, "1 minute"],
339
+ [60, "1 hour"],
340
+ [90, "1 hour 30 minutes"],
341
+ [120, "2 hours"],
342
+ [1440, "1 day"],
343
+ [2880, "2 days"],
344
+ [10080, "1 week"],
345
+ [20160, "2 weeks"],
346
+ ])("%d minutes -> %s", (value: number, expected: string) => {
347
+ expect(OnCallShiftReminderRunner.describeMinutes(value)).toBe(expected);
348
+ });
349
+
350
+ test("keeps at most two units so the text stays readable", () => {
351
+ // 1 week 2 days 3 hours 4 minutes -> the two largest.
352
+ expect(
353
+ OnCallShiftReminderRunner.describeMinutes(
354
+ 7 * 24 * 60 + 2 * 24 * 60 + 3 * 60 + 4,
355
+ ),
356
+ ).toBe("1 week 2 days");
357
+ });
358
+
359
+ test("rounds fractional minutes and never says zero", () => {
360
+ expect(OnCallShiftReminderRunner.describeMinutes(14.6)).toBe(
361
+ "15 minutes",
362
+ );
363
+ expect(OnCallShiftReminderRunner.describeMinutes(0.2)).toBe(
364
+ "less than a minute",
365
+ );
366
+ expect(OnCallShiftReminderRunner.describeMinutes(-3)).toBe(
367
+ "less than a minute",
368
+ );
369
+ });
370
+ });
371
+
372
+ describe("describeRemaining", () => {
373
+ test("says the configured lead when the tick is at most one tick behind", () => {
374
+ expect(
375
+ OnCallShiftReminderRunner.describeRemaining({
376
+ lead: 60,
377
+ start: SHIFT_START,
378
+ now: minutes(SHIFT_START, -57),
379
+ }),
380
+ ).toBe("1 hour");
381
+ });
382
+
383
+ test("says the true remaining time when the tick is later than that", () => {
384
+ expect(
385
+ OnCallShiftReminderRunner.describeRemaining({
386
+ lead: 60,
387
+ start: SHIFT_START,
388
+ now: minutes(SHIFT_START, -50),
389
+ }),
390
+ ).toBe("50 minutes");
391
+ });
392
+
393
+ test("never claims the lead when more than the lead remains", () => {
394
+ expect(
395
+ OnCallShiftReminderRunner.describeRemaining({
396
+ lead: 60,
397
+ start: SHIFT_START,
398
+ now: minutes(SHIFT_START, -75),
399
+ }),
400
+ ).toBe("1 hour 15 minutes");
401
+ });
402
+ });
403
+
404
+ describe("resolveTimezone", () => {
405
+ test("prefers the recipient's zone, then the schedule's, then UTC", () => {
406
+ expect(
407
+ OnCallShiftReminderRunner.resolveTimezone({
408
+ userTimezone: "Europe/Berlin",
409
+ scheduleTimezone: "Europe/Stockholm",
410
+ }),
411
+ ).toBe("Europe/Berlin");
412
+ expect(
413
+ OnCallShiftReminderRunner.resolveTimezone({
414
+ scheduleTimezone: "Europe/Stockholm",
415
+ }),
416
+ ).toBe("Europe/Stockholm");
417
+ expect(OnCallShiftReminderRunner.resolveTimezone({})).toBe(Timezone.UTC);
418
+ });
419
+
420
+ test("skips an invalid zone rather than crashing the formatter", () => {
421
+ expect(
422
+ OnCallShiftReminderRunner.resolveTimezone({
423
+ userTimezone: "Mars/Olympus",
424
+ scheduleTimezone: "Europe/Stockholm",
425
+ }),
426
+ ).toBe("Europe/Stockholm");
427
+ expect(
428
+ OnCallShiftReminderRunner.resolveTimezone({
429
+ userTimezone: "Mars/Olympus",
430
+ scheduleTimezone: "",
431
+ }),
432
+ ).toBe(Timezone.UTC);
433
+ });
434
+ });
435
+
436
+ describe("formatInstant", () => {
437
+ test("renders the wall clock in the given zone with the zone name", () => {
438
+ expect(
439
+ OnCallShiftReminderRunner.formatInstant(SHIFT_START, "Europe/Berlin"),
440
+ ).toBe(BERLIN_START_TEXT);
441
+ expect(
442
+ OnCallShiftReminderRunner.formatInstant(
443
+ SHIFT_START,
444
+ "America/New_York",
445
+ ),
446
+ ).toBe(NEW_YORK_START_TEXT);
447
+ expect(OnCallShiftReminderRunner.formatInstant(SHIFT_START, "UTC")).toBe(
448
+ "Thu 3 Sep 16:00 UTC",
449
+ );
450
+ });
451
+
452
+ test("is DST-aware: the same UTC hour is 03:00 CEST in summer and 02:00 CET in winter", () => {
453
+ expect(
454
+ OnCallShiftReminderRunner.formatInstant(
455
+ at("2026-07-15T01:00:00Z"),
456
+ "Europe/Berlin",
457
+ ),
458
+ ).toBe("Wed 15 Jul 03:00 Europe/Berlin");
459
+ expect(
460
+ OnCallShiftReminderRunner.formatInstant(
461
+ at("2026-01-15T01:00:00Z"),
462
+ "Europe/Berlin",
463
+ ),
464
+ ).toBe("Thu 15 Jan 02:00 Europe/Berlin");
465
+ });
466
+ });
467
+
468
+ describe("describePolicyNames", () => {
469
+ test("lists each policy once, alphabetically", () => {
470
+ expect(
471
+ OnCallShiftReminderRunner.describePolicyNames([
472
+ { ...DEFAULT_POLICY, policyName: "Zeta" },
473
+ { ...DEFAULT_POLICY, policyId: "pol-2", policyName: "Alpha" },
474
+ { ...DEFAULT_POLICY, policyName: "Zeta", ruleId: "rule-2" },
475
+ ]),
476
+ ).toBe("Alpha, Zeta");
477
+ });
478
+ });
479
+
480
+ describe("ledgerKey", () => {
481
+ test("truncates the start to the minute, so a start moved by seconds is the same reminder", () => {
482
+ const base: string = OnCallShiftReminderRunner.ledgerKey({
483
+ userId: USER_A,
484
+ scheduleId: SCHEDULE,
485
+ shiftStartsAt: SHIFT_START,
486
+ minutesBeforeShift: 60,
487
+ kind: UserOnCallShiftReminderLogKind.Reminder,
488
+ });
489
+
490
+ expect(
491
+ OnCallShiftReminderRunner.ledgerKey({
492
+ userId: USER_A.toString(),
493
+ scheduleId: new ObjectID(SCHEDULE),
494
+ shiftStartsAt: new Date(SHIFT_START.getTime() + 59 * 1000 + 999),
495
+ minutesBeforeShift: 60,
496
+ kind: UserOnCallShiftReminderLogKind.Reminder,
497
+ }),
498
+ ).toBe(base);
499
+ });
500
+
501
+ test("differs by user, schedule, minute, lead and kind", () => {
502
+ const keys: Set<string> = new Set<string>([
503
+ OnCallShiftReminderRunner.ledgerKey({
504
+ userId: USER_A,
505
+ scheduleId: SCHEDULE,
506
+ shiftStartsAt: SHIFT_START,
507
+ minutesBeforeShift: 60,
508
+ kind: UserOnCallShiftReminderLogKind.Reminder,
509
+ }),
510
+ OnCallShiftReminderRunner.ledgerKey({
511
+ userId: USER_B,
512
+ scheduleId: SCHEDULE,
513
+ shiftStartsAt: SHIFT_START,
514
+ minutesBeforeShift: 60,
515
+ kind: UserOnCallShiftReminderLogKind.Reminder,
516
+ }),
517
+ OnCallShiftReminderRunner.ledgerKey({
518
+ userId: USER_A,
519
+ scheduleId: SCHEDULE_2,
520
+ shiftStartsAt: SHIFT_START,
521
+ minutesBeforeShift: 60,
522
+ kind: UserOnCallShiftReminderLogKind.Reminder,
523
+ }),
524
+ OnCallShiftReminderRunner.ledgerKey({
525
+ userId: USER_A,
526
+ scheduleId: SCHEDULE,
527
+ shiftStartsAt: minutes(SHIFT_START, 1),
528
+ minutesBeforeShift: 60,
529
+ kind: UserOnCallShiftReminderLogKind.Reminder,
530
+ }),
531
+ OnCallShiftReminderRunner.ledgerKey({
532
+ userId: USER_A,
533
+ scheduleId: SCHEDULE,
534
+ shiftStartsAt: SHIFT_START,
535
+ minutesBeforeShift: 15,
536
+ kind: UserOnCallShiftReminderLogKind.Reminder,
537
+ }),
538
+ OnCallShiftReminderRunner.ledgerKey({
539
+ userId: USER_A,
540
+ scheduleId: SCHEDULE,
541
+ shiftStartsAt: SHIFT_START,
542
+ minutesBeforeShift: 60,
543
+ kind: UserOnCallShiftReminderLogKind.CatchUp,
544
+ }),
545
+ ]);
546
+
547
+ expect(keys.size).toBe(6);
548
+ });
549
+ });
550
+
551
+ describe("message builders", () => {
552
+ test("buildReminderMessage: schedule, policies, lead and the recipient's wall clock", () => {
553
+ const message: ShiftReminderMessage =
554
+ OnCallShiftReminderRunner.buildReminderMessage({
555
+ shift: aliceShift(),
556
+ lead: 60,
557
+ now: NOW,
558
+ timezone: "Europe/Berlin",
559
+ dashboardUrl: HARNESS_DASHBOARD_URL,
560
+ });
561
+
562
+ expect(message.text).toContain(
563
+ `Your on-call shift on Payments for Payments Policy starts in 1 hour (${BERLIN_START_TEXT}).`,
564
+ );
565
+ expect(message.subject).toBe(
566
+ "Reminder: your on-call shift on Payments starts in 1 hour",
567
+ );
568
+ expect(message.pushTitle).toBe("On-call shift reminder");
569
+ expect(message.pushBody).toBe(
570
+ `Your on-call shift on Payments starts in 1 hour (${BERLIN_START_TEXT}).`,
571
+ );
572
+ expect(message.templateType).toBe(
573
+ EmailTemplateType.UserOnCallShiftReminder,
574
+ );
575
+ expect(message.eventType).toBe(
576
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
577
+ );
578
+ expect(message.timezone).toBe("Europe/Berlin");
579
+ expect(message.whenText).toBe(BERLIN_START_TEXT);
580
+ expect(message.vars).toMatchObject({
581
+ scheduleName: "Payments",
582
+ policyNames: "Payments Policy",
583
+ leadText: "1 hour",
584
+ remainingText: "1 hour",
585
+ startsAt: BERLIN_START_TEXT,
586
+ endsAt: "Fri 4 Sep 18:00 Europe/Berlin",
587
+ timezone: "Europe/Berlin",
588
+ coveringFor: "",
589
+ scheduleViewLink: `${HARNESS_DASHBOARD_URL}/${PROJECT.toString()}/on-call-duty/schedules/${SCHEDULE}`,
590
+ });
591
+ expect(message.text).not.toContain("covering for");
592
+ });
593
+
594
+ test("buildReminderMessage: a covering shift names who is being covered", () => {
595
+ const message: ShiftReminderMessage =
596
+ OnCallShiftReminderRunner.buildReminderMessage({
597
+ shift: bobCoveringForAlice(),
598
+ lead: 60,
599
+ now: NOW,
600
+ timezone: "America/New_York",
601
+ dashboardUrl: HARNESS_DASHBOARD_URL,
602
+ });
603
+
604
+ expect(message.text).toContain(
605
+ `starts in 1 hour (${NEW_YORK_START_TEXT}) (you are covering for Alice Andersson).`,
606
+ );
607
+ expect(message.vars["coveringFor"]).toBe("Alice Andersson");
608
+ });
609
+
610
+ test("buildReminderMessage: a late tick says the true remaining time, not the lead", () => {
611
+ const message: ShiftReminderMessage =
612
+ OnCallShiftReminderRunner.buildReminderMessage({
613
+ shift: aliceShift(),
614
+ lead: 60,
615
+ now: minutes(SHIFT_START, -40),
616
+ timezone: "UTC",
617
+ dashboardUrl: HARNESS_DASHBOARD_URL,
618
+ });
619
+
620
+ expect(message.text).toContain("starts in 40 minutes (");
621
+ // The lead itself is still reported so the recipient knows which reminder this is.
622
+ expect(message.vars["leadText"]).toBe("1 hour");
623
+ expect(message.vars["remainingText"]).toBe("40 minutes");
624
+ });
625
+
626
+ test("buildCatchUpMessage: 'Heads up', covering-for and the reminder template", () => {
627
+ const message: ShiftReminderMessage =
628
+ OnCallShiftReminderRunner.buildCatchUpMessage({
629
+ shift: bobCoveringForAlice(),
630
+ now: minutes(SHIFT_START, -28),
631
+ timezone: "America/New_York",
632
+ dashboardUrl: HARNESS_DASHBOARD_URL,
633
+ });
634
+
635
+ expect(message.text).toContain(
636
+ `Heads up: your on-call shift on Payments for Payments Policy starts in 28 minutes (${NEW_YORK_START_TEXT}) (you are covering for Alice Andersson).`,
637
+ );
638
+ expect(message.subject).toBe(
639
+ "Heads up: your on-call shift on Payments starts in 28 minutes",
640
+ );
641
+ expect(message.pushBody).toContain("starts in 28 minutes");
642
+ expect(message.templateType).toBe(
643
+ EmailTemplateType.UserOnCallShiftReminder,
644
+ );
645
+ expect(message.eventType).toBe(
646
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
647
+ );
648
+ expect(message.vars["leadText"]).toBe("28 minutes");
649
+ });
650
+
651
+ test("buildCatchUpMessage never claims the shift changed: a catch-up also goes to shifts that did not move", () => {
652
+ /*
653
+ * A catch-up reaches anyone holding a shift inside a lead with no
654
+ * reminder row — including someone who configured the lead after its
655
+ * instant had passed. "now starts in ..." would tell them a colleague's
656
+ * unrelated layer edit had moved their shift.
657
+ */
658
+ const message: ShiftReminderMessage =
659
+ OnCallShiftReminderRunner.buildCatchUpMessage({
660
+ shift: aliceShift(),
661
+ now: minutes(SHIFT_START, -1320),
662
+ timezone: "Europe/Berlin",
663
+ dashboardUrl: HARNESS_DASHBOARD_URL,
664
+ });
665
+
666
+ expect(message.text).toContain(
667
+ `Heads up: your on-call shift on Payments for Payments Policy starts in 22 hours (${BERLIN_START_TEXT}).`,
668
+ );
669
+ expect(message.text).not.toContain("now starts in");
670
+ expect(message.subject).not.toContain("now starts in");
671
+ expect(message.pushBody).not.toContain("now starts in");
672
+ });
673
+
674
+ test("buildReassignedMessage: names the replacement, or says the shift is no longer yours", () => {
675
+ const covered: ShiftReminderMessage =
676
+ OnCallShiftReminderRunner.buildReassignedMessage({
677
+ scheduleName: "Payments",
678
+ projectId: PROJECT.toString(),
679
+ scheduleId: SCHEDULE,
680
+ shiftStartsAt: SHIFT_START,
681
+ coveredBy: "Bob Brown",
682
+ timezone: "Europe/Berlin",
683
+ dashboardUrl: HARNESS_DASHBOARD_URL,
684
+ });
685
+
686
+ expect(covered.text).toContain(
687
+ `Your on-call shift on Payments at ${BERLIN_START_TEXT} is now covered by Bob Brown.`,
688
+ );
689
+ expect(covered.subject).toBe(
690
+ "Your on-call shift on Payments is now covered by Bob Brown",
691
+ );
692
+ expect(covered.pushTitle).toBe("On-call shift reassigned");
693
+ expect(covered.templateType).toBe(
694
+ EmailTemplateType.UserOnCallShiftReassigned,
695
+ );
696
+ expect(covered.eventType).toBe(
697
+ NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
698
+ );
699
+ expect(covered.vars).toMatchObject({
700
+ scheduleName: "Payments",
701
+ startsAt: BERLIN_START_TEXT,
702
+ coveredBy: "Bob Brown",
703
+ });
704
+
705
+ const unassigned: ShiftReminderMessage =
706
+ OnCallShiftReminderRunner.buildReassignedMessage({
707
+ scheduleName: "Payments",
708
+ projectId: PROJECT.toString(),
709
+ scheduleId: SCHEDULE,
710
+ shiftStartsAt: SHIFT_START,
711
+ coveredBy: null,
712
+ timezone: "Europe/Berlin",
713
+ dashboardUrl: HARNESS_DASHBOARD_URL,
714
+ });
715
+
716
+ expect(unassigned.text).toContain("is no longer assigned to you.");
717
+ expect(unassigned.vars["coveredBy"]).toBe("");
718
+ });
719
+ });
720
+
721
+ /*
722
+ * ---------------------------------------------------------------------
723
+ * The sweep
724
+ * ---------------------------------------------------------------------
725
+ */
726
+
727
+ /*
728
+ * WhatsApp is template-only: the notification service rejects a body-only
729
+ * payload before it reaches Meta (a failed log row and nothing delivered),
730
+ * so a reminder rides the approved "you are next on-call" template and a
731
+ * reassignment — which no approved template describes — sends no WhatsApp
732
+ * payload at all, leaving the channel skipped rather than failed.
733
+ */
734
+ describe("WhatsApp payloads", () => {
735
+ test("a reminder uses the approved on-call template, with the schedule and policy filled in", () => {
736
+ const payload: WhatsAppMessagePayload | undefined =
737
+ OnCallShiftReminderRunner.buildWhatsAppMessage(
738
+ OnCallShiftReminderRunner.buildReminderMessage({
739
+ shift: aliceShift(),
740
+ lead: 60,
741
+ now: NOW,
742
+ timezone: "Europe/Berlin",
743
+ dashboardUrl: HARNESS_DASHBOARD_URL,
744
+ }),
745
+ );
746
+
747
+ expect(payload).toBeDefined();
748
+ expect(payload!.templateKey).toBe(
749
+ WhatsAppTemplateIds.OnCallUserIsNextNotification,
750
+ );
751
+ expect(payload!.templateLanguageCode).toBe("en");
752
+ expect(payload!.body).toContain("You are next on-call");
753
+ expect(payload!.body).toContain("Payments Policy");
754
+ expect(payload!.body).toContain("Payments");
755
+ expect(payload!.templateVariables?.["schedule_link"]).toContain(
756
+ HARNESS_DASHBOARD_URL,
757
+ );
758
+ });
759
+
760
+ test("a catch-up uses the same template", () => {
761
+ const payload: WhatsAppMessagePayload | undefined =
762
+ OnCallShiftReminderRunner.buildWhatsAppMessage(
763
+ OnCallShiftReminderRunner.buildCatchUpMessage({
764
+ shift: bobCoveringForAlice(),
765
+ now: minutes(SHIFT_START, -10),
766
+ timezone: "America/New_York",
767
+ dashboardUrl: HARNESS_DASHBOARD_URL,
768
+ }),
769
+ );
770
+
771
+ expect(payload!.templateKey).toBe(
772
+ WhatsAppTemplateIds.OnCallUserIsNextNotification,
773
+ );
774
+ });
775
+
776
+ test("a reassigned notice sends NO WhatsApp payload, so the channel is skipped and never fails", () => {
777
+ const payload: WhatsAppMessagePayload | undefined =
778
+ OnCallShiftReminderRunner.buildWhatsAppMessage(
779
+ OnCallShiftReminderRunner.buildReassignedMessage({
780
+ scheduleName: "Payments",
781
+ projectId: PROJECT.toString(),
782
+ scheduleId: SCHEDULE,
783
+ shiftStartsAt: SHIFT_START,
784
+ coveredBy: "Bob Brown",
785
+ timezone: "Europe/Berlin",
786
+ dashboardUrl: HARNESS_DASHBOARD_URL,
787
+ }),
788
+ );
789
+
790
+ expect(payload).toBeUndefined();
791
+ });
792
+
793
+ test("no reminder ever leaves with a body-only payload (which the notification service rejects)", async () => {
794
+ harness.reminders = [reminder(USER_A, 60)];
795
+ harness.shifts = [aliceShift()];
796
+
797
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
798
+
799
+ expect(harness.sent).toHaveLength(1);
800
+ expect(harness.sent[0]!.whatsAppTemplateKey).toBe(
801
+ WhatsAppTemplateIds.OnCallUserIsNextNotification,
802
+ );
803
+ });
804
+
805
+ test("the reassigned notice reaches the delivery path with no WhatsApp payload", async () => {
806
+ seedLedgerRow(harness, {
807
+ projectId: PROJECT,
808
+ userId: USER_A,
809
+ scheduleId: SCHEDULE,
810
+ shiftStartsAt: SHIFT_START,
811
+ minutesBeforeShift: 60,
812
+ kind: UserOnCallShiftReminderLogKind.Reminder,
813
+ claimedAt: minutes(SHIFT_START, -60),
814
+ sentAt: minutes(SHIFT_START, -60),
815
+ });
816
+ harness.shifts = [bobCoveringForAlice()];
817
+
818
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
819
+ now: minutes(SHIFT_START, -40),
820
+ });
821
+
822
+ expect(harness.sent).toHaveLength(1);
823
+ expect(harness.sent[0]!.eventType).toBe(
824
+ NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
825
+ );
826
+ expect(harness.sent[0]!.whatsAppTemplateKey).toBeUndefined();
827
+ expect(harness.sent[0]!.whatsAppBody).toBeUndefined();
828
+ });
829
+ });
830
+
831
+ describe("runSweep", () => {
832
+ test("sends one reminder for a due (shift, lead): claim, send, stamp — in the recipient's timezone", async () => {
833
+ harness.reminders = [reminder(USER_A, 60)];
834
+ harness.shifts = [aliceShift()];
835
+
836
+ const stats: ShiftReminderSweepStats =
837
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
838
+
839
+ expect(stats.sent).toBe(1);
840
+ expect(stats.projects).toBe(1);
841
+ expect(stats.usersWithReminders).toBe(1);
842
+ expect(stats.shiftsConsidered).toBe(1);
843
+ expect(stats.watermarkFound).toBe(false);
844
+ expect(stats.errors).toBe(0);
845
+
846
+ expect(harness.sent).toHaveLength(1);
847
+ const call: SentNotification = harness.sent[0]!;
848
+
849
+ expect(call.userId).toBe(USER_A.toString());
850
+ expect(call.projectId).toBe(PROJECT.toString());
851
+ expect(call.eventType).toBe(
852
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
853
+ );
854
+ expect(call.templateType).toBe(EmailTemplateType.UserOnCallShiftReminder);
855
+ expect(call.smsText).toContain(`starts in 1 hour (${BERLIN_START_TEXT})`);
856
+ expect(call.vars["startsAt"]).toBe(BERLIN_START_TEXT);
857
+ expect(call.onCallScheduleId).toBe(SCHEDULE);
858
+ expect(call.onCallPolicyId).toBe(DEFAULT_POLICY.policyId);
859
+ expect(call.onCallPolicyEscalationRuleId).toBe(DEFAULT_POLICY.ruleId);
860
+
861
+ // The ledger row: claimed at the tick, stamped after the send.
862
+ expect(harness.ledger).toHaveLength(1);
863
+ const row: FakeLedgerRow = harness.ledger[0]!;
864
+
865
+ expect(row.kind).toBe(UserOnCallShiftReminderLogKind.Reminder);
866
+ expect(row.minutesBeforeShift).toBe(60);
867
+ expect(row.userId.toString()).toBe(USER_A.toString());
868
+ expect(row.projectId.toString()).toBe(PROJECT.toString());
869
+ expect(row.onCallDutyPolicyScheduleId.toString()).toBe(SCHEDULE);
870
+ expect(row.shiftStartsAt.toISOString()).toBe(SHIFT_START.toISOString());
871
+ expect(row.claimedAt.toISOString()).toBe(NOW.toISOString());
872
+ expect(row.sentAt).not.toBeNull();
873
+
874
+ // The watermark advanced to this tick only after the pass completed.
875
+ expect(watermarkOf(harness)).toBe(NOW.toISOString());
876
+
877
+ expect(outcomes(harness)).toContain(ShiftReminderOutcome.Sent);
878
+ });
879
+
880
+ test("formats the same shift in each recipient's own zone", async () => {
881
+ harness.reminders = [reminder(USER_A, 60), reminder(USER_B, 60)];
882
+ harness.shifts = [
883
+ aliceShift(),
884
+ aliceShift({
885
+ scheduleId: SCHEDULE_2,
886
+ scheduleName: "Database",
887
+ userId: USER_B.toString(),
888
+ userName: "Bob Brown",
889
+ }),
890
+ ];
891
+
892
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
893
+
894
+ const byUser: Map<string, SentNotification> = new Map<
895
+ string,
896
+ SentNotification
897
+ >();
898
+
899
+ for (const call of harness.sent) {
900
+ byUser.set(call.userId, call);
901
+ }
902
+
903
+ expect(byUser.get(USER_A.toString())?.vars["startsAt"]).toBe(
904
+ BERLIN_START_TEXT,
905
+ );
906
+ expect(byUser.get(USER_B.toString())?.vars["startsAt"]).toBe(
907
+ NEW_YORK_START_TEXT,
908
+ );
909
+ });
910
+
911
+ test("a recipient without a timezone gets the schedule's zone, and UTC when the schedule has none", async () => {
912
+ harness.reminders = [reminder(USER_C, 60)];
913
+ harness.shifts = [
914
+ aliceShift({ userId: USER_C.toString(), userName: "Carol Chen" }),
915
+ ];
916
+
917
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
918
+
919
+ expect(harness.sent[0]?.vars["startsAt"]).toBe(
920
+ "Thu 3 Sep 18:00 Europe/Stockholm",
921
+ );
922
+
923
+ harness.sent = [];
924
+ harness.ledger = [];
925
+ harness.cache.clear();
926
+ harness.shifts = [
927
+ shift({
928
+ scheduleId: SCHEDULE,
929
+ projectId: PROJECT.toString(),
930
+ userId: USER_C.toString(),
931
+ start: SHIFT_START,
932
+ end: SHIFT_END,
933
+ scheduleTimezone: undefined,
934
+ }),
935
+ ];
936
+
937
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
938
+
939
+ expect(harness.sent[0]?.vars["startsAt"]).toBe("Thu 3 Sep 16:00 UTC");
940
+ });
941
+
942
+ test("materializes [now, now + longest lead + 30 min] once per project, for every reminded user's schedules", async () => {
943
+ harness.reminders = [
944
+ reminder(USER_A, 60),
945
+ reminder(USER_A, 1440),
946
+ reminder(USER_B, 15),
947
+ ];
948
+ harness.candidateSchedules.set(USER_A.toString(), [SCHEDULE]);
949
+ harness.candidateSchedules.set(USER_B.toString(), [SCHEDULE_2, SCHEDULE]);
950
+
951
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
952
+
953
+ expect(harness.materializeCalls).toHaveLength(1);
954
+ expect(harness.materializeCalls[0]!.scheduleIds.sort()).toEqual([
955
+ SCHEDULE,
956
+ SCHEDULE_2,
957
+ ]);
958
+ expect(harness.materializeCalls[0]!.windowStart.toISOString()).toBe(
959
+ NOW.toISOString(),
960
+ );
961
+ expect(harness.materializeCalls[0]!.windowEnd.toISOString()).toBe(
962
+ minutes(
963
+ NOW,
964
+ 1440 + SHIFT_REMINDER_WINDOW_PADDING_MINUTES,
965
+ ).toISOString(),
966
+ );
967
+ expect(harness.materializeCalls[0]!.now?.toISOString()).toBe(
968
+ NOW.toISOString(),
969
+ );
970
+ /*
971
+ * ONE batched candidate lookup for the whole project, not one per
972
+ * reminded user (which was 2-3 queries per user, every five minutes).
973
+ */
974
+ expect(harness.candidateCalls).toHaveLength(1);
975
+ expect(harness.candidateCalls[0]!.projectIds).toEqual([
976
+ PROJECT.toString(),
977
+ ]);
978
+ expect(harness.candidateCalls[0]!.userIds.sort()).toEqual(
979
+ [USER_A.toString(), USER_B.toString()].sort(),
980
+ );
981
+ });
982
+
983
+ test("with no reminders configured it touches nothing and still advances the watermark", async () => {
984
+ const stats: ShiftReminderSweepStats =
985
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
986
+
987
+ expect(stats.projects).toBe(0);
988
+ expect(harness.materializeCalls).toHaveLength(0);
989
+ expect(harness.sent).toHaveLength(0);
990
+ expect(watermarkOf(harness)).toBe(NOW.toISOString());
991
+ });
992
+
993
+ describe("watermark", () => {
994
+ test("a skipped tick is caught up by the next one", async () => {
995
+ harness.reminders = [reminder(USER_A, 60)];
996
+ harness.shifts = [aliceShift()];
997
+
998
+ // 14:40 — lead instant (15:00) still ahead: nothing due.
999
+ const first: ShiftReminderSweepStats =
1000
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, -20) });
1001
+
1002
+ expect(first.sent).toBe(0);
1003
+ expect(watermarkOf(harness)).toBe(minutes(NOW, -20).toISOString());
1004
+
1005
+ // 14:45, 14:50, 14:55, 15:00 and 15:05 never ran. 15:07 does.
1006
+ const late: ShiftReminderSweepStats =
1007
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 7) });
1008
+
1009
+ expect(late.watermarkFound).toBe(true);
1010
+ expect(late.lookbackFrom.toISOString()).toBe(
1011
+ minutes(NOW, -20).toISOString(),
1012
+ );
1013
+ expect(late.sent).toBe(1);
1014
+ // More than one tick behind the lead instant: the true remaining time.
1015
+ expect(harness.sent[0]!.smsText).toContain("starts in 53 minutes");
1016
+ expect(watermarkOf(harness)).toBe(minutes(NOW, 7).toISOString());
1017
+ });
1018
+
1019
+ test("with no watermark (Redis restart) the window reaches back 30 minutes, and the ledger stops duplicates", async () => {
1020
+ harness.reminders = [reminder(USER_A, 60)];
1021
+ harness.shifts = [aliceShift()];
1022
+
1023
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1024
+ expect(harness.sent).toHaveLength(1);
1025
+
1026
+ // Redis flushed: the next tick re-considers the last 30 minutes.
1027
+ harness.cache.clear();
1028
+
1029
+ const stats: ShiftReminderSweepStats =
1030
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 2) });
1031
+
1032
+ expect(stats.watermarkFound).toBe(false);
1033
+ expect(stats.lookbackFrom.toISOString()).toBe(
1034
+ minutes(NOW, 2 - SHIFT_REMINDER_MAX_LOOKBACK_MINUTES).toISOString(),
1035
+ );
1036
+ expect(stats.skippedAlreadySent).toBe(1);
1037
+ expect(stats.sent).toBe(0);
1038
+ expect(harness.sent).toHaveLength(1);
1039
+ expect(harness.ledger).toHaveLength(1);
1040
+ expect(outcomes(harness)).toContain(
1041
+ ShiftReminderOutcome.SkippedAlreadySent,
1042
+ );
1043
+ });
1044
+
1045
+ test("an ancient watermark is capped: reminders whose lead instant fell in a long outage are dropped, not flooded", async () => {
1046
+ harness.reminders = [reminder(USER_A, 60)];
1047
+ // Lead instant was 45 minutes ago (start = now + 15).
1048
+ harness.shifts = [
1049
+ aliceShift({ start: minutes(NOW, 15), end: minutes(NOW, 600) }),
1050
+ ];
1051
+ harness.cache.set(
1052
+ "OnCallShiftReminders:watermark",
1053
+ minutes(NOW, -120).toISOString(),
1054
+ );
1055
+
1056
+ const stats: ShiftReminderSweepStats =
1057
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1058
+
1059
+ expect(stats.lookbackFrom.toISOString()).toBe(
1060
+ minutes(NOW, -30).toISOString(),
1061
+ );
1062
+ expect(stats.shiftsConsidered).toBe(1);
1063
+ expect(stats.sent).toBe(0);
1064
+ expect(harness.sent).toHaveLength(0);
1065
+ expect(harness.ledger).toHaveLength(0);
1066
+ });
1067
+
1068
+ test("an unreadable watermark falls back to the 30-minute lookback with a warning", async () => {
1069
+ harness.cacheReadError = new Error("redis down");
1070
+ harness.reminders = [reminder(USER_A, 60)];
1071
+ harness.shifts = [aliceShift()];
1072
+
1073
+ const stats: ShiftReminderSweepStats =
1074
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1075
+
1076
+ expect(stats.watermarkFound).toBe(false);
1077
+ expect(stats.sent).toBe(1);
1078
+ expect(
1079
+ harness.warnings.some((line: string) => {
1080
+ return line.includes("could not read the watermark");
1081
+ }),
1082
+ ).toBe(true);
1083
+ });
1084
+
1085
+ test("an unwritable watermark does not fail the sweep", async () => {
1086
+ harness.cacheWriteError = new Error("redis down");
1087
+ harness.reminders = [reminder(USER_A, 60)];
1088
+ harness.shifts = [aliceShift()];
1089
+
1090
+ const stats: ShiftReminderSweepStats =
1091
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1092
+
1093
+ expect(stats.sent).toBe(1);
1094
+ expect(
1095
+ harness.warnings.some((line: string) => {
1096
+ return line.includes("could not write the watermark");
1097
+ }),
1098
+ ).toBe(true);
1099
+ });
1100
+
1101
+ test("the watermark is written only after every project was processed", async () => {
1102
+ harness.reminders = [reminder(USER_A, 60)];
1103
+ harness.shifts = [aliceShift()];
1104
+
1105
+ const order: Array<string> = [];
1106
+
1107
+ jest
1108
+ .spyOn(UserNotificationSettingService, "sendUserNotification")
1109
+ .mockImplementation((): Promise<void> => {
1110
+ order.push("send");
1111
+ return Promise.resolve();
1112
+ });
1113
+
1114
+ const setString: any = jest.spyOn(harness.cache, "set");
1115
+ setString.mockImplementation(
1116
+ (key: string, value: string): Map<string, string> => {
1117
+ order.push("watermark");
1118
+ return Map.prototype.set.call(harness.cache, key, value);
1119
+ },
1120
+ );
1121
+
1122
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1123
+
1124
+ expect(order).toEqual(["send", "watermark"]);
1125
+ });
1126
+ });
1127
+
1128
+ describe("lateness cap", () => {
1129
+ test("never reminds about a shift that already started, even when its lead instant is inside the window", async () => {
1130
+ harness.reminders = [reminder(USER_A, 15)];
1131
+ // Started 5 minutes ago; lead instant (now - 20) is inside a 25-minute lookback.
1132
+ harness.shifts = [
1133
+ aliceShift({ start: minutes(NOW, -5), end: minutes(NOW, 600) }),
1134
+ ];
1135
+ harness.cache.set(
1136
+ "OnCallShiftReminders:watermark",
1137
+ minutes(NOW, -25).toISOString(),
1138
+ );
1139
+
1140
+ const stats: ShiftReminderSweepStats =
1141
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1142
+
1143
+ expect(stats.skippedLate).toBe(1);
1144
+ expect(stats.sent).toBe(0);
1145
+ expect(harness.sent).toHaveLength(0);
1146
+ expect(harness.ledger).toHaveLength(0);
1147
+ expect(outcomes(harness)).toContain(ShiftReminderOutcome.SkippedLate);
1148
+ });
1149
+
1150
+ test("a shift starting exactly now is late, one starting a minute later is not", async () => {
1151
+ harness.reminders = [reminder(USER_A, 15)];
1152
+ harness.shifts = [
1153
+ aliceShift({ start: NOW, end: minutes(NOW, 600) }),
1154
+ aliceShift({
1155
+ scheduleId: SCHEDULE_2,
1156
+ start: minutes(NOW, 1),
1157
+ end: minutes(NOW, 600),
1158
+ }),
1159
+ ];
1160
+ harness.cache.set(
1161
+ "OnCallShiftReminders:watermark",
1162
+ minutes(NOW, -25).toISOString(),
1163
+ );
1164
+
1165
+ const stats: ShiftReminderSweepStats =
1166
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1167
+
1168
+ expect(stats.skippedLate).toBe(1);
1169
+ expect(stats.sent).toBe(1);
1170
+ expect(harness.sent[0]!.onCallScheduleId).toBe(SCHEDULE_2);
1171
+ });
1172
+ });
1173
+
1174
+ describe("claim -> send -> stamp", () => {
1175
+ test("a thrown send releases the claim, and the next tick inside the lead retries", async () => {
1176
+ harness.reminders = [reminder(USER_A, 60)];
1177
+ harness.shifts = [aliceShift()];
1178
+
1179
+ let attempts: number = 0;
1180
+
1181
+ harness.failSend = (): Error | null => {
1182
+ attempts++;
1183
+ return attempts === 1 ? new Error("smtp down") : null;
1184
+ };
1185
+
1186
+ const first: ShiftReminderSweepStats =
1187
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1188
+
1189
+ expect(first.sendFailures).toBe(1);
1190
+ expect(first.sent).toBe(0);
1191
+ expect(harness.sent).toHaveLength(0);
1192
+ // The claim was released so nothing blocks the retry.
1193
+ expect(harness.ledger).toHaveLength(0);
1194
+ expect(outcomes(harness)).toContain(ShiftReminderOutcome.SendFailed);
1195
+ expect(
1196
+ harness.errors.some((entry: unknown) => {
1197
+ return String(entry).includes("releasing the claim");
1198
+ }),
1199
+ ).toBe(true);
1200
+
1201
+ /*
1202
+ * Next tick, still 57 minutes before the shift; the lead instant is
1203
+ * behind the watermark now, so it takes the fallback lookback to
1204
+ * re-consider it (same thing a Redis restart would do).
1205
+ */
1206
+ harness.cache.clear();
1207
+
1208
+ const second: ShiftReminderSweepStats =
1209
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 3) });
1210
+
1211
+ expect(second.sent).toBe(1);
1212
+ expect(harness.sent).toHaveLength(1);
1213
+ expect(harness.ledger).toHaveLength(1);
1214
+ expect(harness.ledger[0]!.sentAt).not.toBeNull();
1215
+ });
1216
+
1217
+ test("a retried reminder after a failed send still says the true remaining time", async () => {
1218
+ harness.reminders = [reminder(USER_A, 60)];
1219
+ harness.shifts = [aliceShift()];
1220
+ harness.failSend = (): Error | null => {
1221
+ return harness.sent.length === 0 && harness.errors.length === 0
1222
+ ? new Error("smtp down")
1223
+ : null;
1224
+ };
1225
+
1226
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1227
+ harness.cache.clear();
1228
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 12) });
1229
+
1230
+ expect(harness.sent).toHaveLength(1);
1231
+ expect(harness.sent[0]!.smsText).toContain("starts in 48 minutes");
1232
+ });
1233
+
1234
+ test("a unique violation on the claim (another replica got there first) is a skip, not an error", async () => {
1235
+ harness.reminders = [reminder(USER_A, 60)];
1236
+ harness.shifts = [aliceShift()];
1237
+
1238
+ // Between this replica's ledger read and its insert, another replica claims the row.
1239
+ harness.beforeLedgerInsert = (): void => {
1240
+ harness.beforeLedgerInsert = null;
1241
+ seedLedgerRow(harness, {
1242
+ projectId: PROJECT,
1243
+ userId: USER_A,
1244
+ scheduleId: SCHEDULE,
1245
+ shiftStartsAt: SHIFT_START,
1246
+ minutesBeforeShift: 60,
1247
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1248
+ claimedAt: NOW,
1249
+ sentAt: null,
1250
+ });
1251
+ };
1252
+
1253
+ const stats: ShiftReminderSweepStats =
1254
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1255
+
1256
+ expect(stats.claimCollisions).toBe(1);
1257
+ expect(stats.sent).toBe(0);
1258
+ expect(stats.errors).toBe(0);
1259
+ expect(harness.sent).toHaveLength(0);
1260
+ expect(harness.ledger).toHaveLength(1);
1261
+ expect(outcomes(harness)).toContain(
1262
+ ShiftReminderOutcome.ClaimCollision,
1263
+ );
1264
+ });
1265
+
1266
+ test("a fresh claim with sentAt NULL belongs to a worker still sending: skipped as in-flight", async () => {
1267
+ harness.reminders = [reminder(USER_A, 60)];
1268
+ harness.shifts = [aliceShift()];
1269
+ seedLedgerRow(harness, {
1270
+ projectId: PROJECT,
1271
+ userId: USER_A,
1272
+ scheduleId: SCHEDULE,
1273
+ shiftStartsAt: SHIFT_START,
1274
+ minutesBeforeShift: 60,
1275
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1276
+ claimedAt: minutes(NOW, -2),
1277
+ sentAt: null,
1278
+ });
1279
+
1280
+ const stats: ShiftReminderSweepStats =
1281
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1282
+
1283
+ expect(stats.skippedInFlight).toBe(1);
1284
+ expect(stats.sent).toBe(0);
1285
+ expect(harness.sent).toHaveLength(0);
1286
+ expect(outcomes(harness)).toContain(
1287
+ ShiftReminderOutcome.SkippedInFlight,
1288
+ );
1289
+ });
1290
+
1291
+ test("a claim older than 10 minutes with sentAt NULL is re-claimed with a conditional update and retried", async () => {
1292
+ harness.reminders = [reminder(USER_A, 60)];
1293
+ harness.shifts = [aliceShift()];
1294
+ const stale: FakeLedgerRow = seedLedgerRow(harness, {
1295
+ projectId: PROJECT,
1296
+ userId: USER_A,
1297
+ scheduleId: SCHEDULE,
1298
+ shiftStartsAt: SHIFT_START,
1299
+ minutesBeforeShift: 60,
1300
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1301
+ claimedAt: minutes(NOW, -(SHIFT_REMINDER_RECLAIM_AFTER_MINUTES + 1)),
1302
+ sentAt: null,
1303
+ });
1304
+
1305
+ const updateOneBy: any = jest.spyOn(
1306
+ UserOnCallShiftReminderLogService,
1307
+ "updateOneBy",
1308
+ );
1309
+
1310
+ const stats: ShiftReminderSweepStats =
1311
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1312
+
1313
+ expect(stats.claimRetries).toBe(1);
1314
+ expect(stats.sent).toBe(1);
1315
+ expect(harness.sent).toHaveLength(1);
1316
+ expect(harness.ledger).toHaveLength(1);
1317
+ expect(harness.ledger[0]!.id.toString()).toBe(stale.id.toString());
1318
+ expect(harness.ledger[0]!.claimedAt.toISOString()).toBe(
1319
+ NOW.toISOString(),
1320
+ );
1321
+ expect(harness.ledger[0]!.sentAt).not.toBeNull();
1322
+ expect(outcomes(harness)).toContain(ShiftReminderOutcome.ClaimRetry);
1323
+
1324
+ // The re-claim is conditional on the row still being stale and unsent.
1325
+ expect(updateOneBy).toHaveBeenCalledTimes(1);
1326
+ const query: Record<string, unknown> = (
1327
+ updateOneBy.mock.calls[0]![0] as unknown as {
1328
+ query: Record<string, unknown>;
1329
+ }
1330
+ ).query;
1331
+
1332
+ expect(query["_id"]).toBeDefined();
1333
+ expect(query["sentAt"]).toBeDefined();
1334
+ expect(query["claimedAt"]).toBeDefined();
1335
+ expect(
1336
+ (
1337
+ updateOneBy.mock.calls[0]![0] as unknown as {
1338
+ props: { isRoot: boolean };
1339
+ }
1340
+ ).props.isRoot,
1341
+ ).toBe(true);
1342
+ });
1343
+
1344
+ test("when the conditional re-claim matches nothing (another worker won) the reminder is skipped", async () => {
1345
+ harness.reminders = [reminder(USER_A, 60)];
1346
+ harness.shifts = [aliceShift()];
1347
+ seedLedgerRow(harness, {
1348
+ projectId: PROJECT,
1349
+ userId: USER_A,
1350
+ scheduleId: SCHEDULE,
1351
+ shiftStartsAt: SHIFT_START,
1352
+ minutesBeforeShift: 60,
1353
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1354
+ claimedAt: minutes(NOW, -20),
1355
+ sentAt: null,
1356
+ });
1357
+ harness.reclaimUpdateOverride = 0;
1358
+
1359
+ const stats: ShiftReminderSweepStats =
1360
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1361
+
1362
+ expect(stats.skippedInFlight).toBe(1);
1363
+ expect(stats.claimRetries).toBe(0);
1364
+ expect(harness.sent).toHaveLength(0);
1365
+ });
1366
+
1367
+ test("a stale claim exactly at the re-claim boundary is re-claimed", async () => {
1368
+ harness.reminders = [reminder(USER_A, 60)];
1369
+ harness.shifts = [aliceShift()];
1370
+ seedLedgerRow(harness, {
1371
+ projectId: PROJECT,
1372
+ userId: USER_A,
1373
+ scheduleId: SCHEDULE,
1374
+ shiftStartsAt: SHIFT_START,
1375
+ minutesBeforeShift: 60,
1376
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1377
+ claimedAt: minutes(NOW, -SHIFT_REMINDER_RECLAIM_AFTER_MINUTES),
1378
+ sentAt: null,
1379
+ });
1380
+
1381
+ const stats: ShiftReminderSweepStats =
1382
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1383
+
1384
+ expect(stats.claimRetries).toBe(1);
1385
+ expect(stats.sent).toBe(1);
1386
+ });
1387
+
1388
+ test("the stamp uses the ledger row created by the claim (claimId), not a fresh lookup", async () => {
1389
+ harness.reminders = [reminder(USER_A, 60)];
1390
+ harness.shifts = [aliceShift()];
1391
+
1392
+ const updateOneById: any = jest.spyOn(
1393
+ UserOnCallShiftReminderLogService,
1394
+ "updateOneById",
1395
+ );
1396
+
1397
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1398
+
1399
+ expect(updateOneById).toHaveBeenCalledTimes(1);
1400
+ const args: { id: ObjectID; data: { sentAt: Date } } = updateOneById
1401
+ .mock.calls[0]![0] as unknown as {
1402
+ id: ObjectID;
1403
+ data: { sentAt: Date };
1404
+ };
1405
+
1406
+ expect(args.id.toString()).toBe(harness.ledger[0]!.id.toString());
1407
+ expect(args.data.sentAt).toBeInstanceOf(Date);
1408
+ });
1409
+ });
1410
+
1411
+ /*
1412
+ * The one duplicate the UNIQUE index cannot catch: a hook-triggered
1413
+ * change pass (in the api process) and this sweep (in the worker)
1414
+ * deciding about the same shift at the same moment. `reminder|lead` and
1415
+ * `catch-up|lead` are different keys, so both inserts succeed — the
1416
+ * post-claim re-read is what keeps exactly one message going out.
1417
+ */
1418
+ describe("a concurrent pass claiming the same shift", () => {
1419
+ function claimedByAnotherPassAt(claimedAt: Date): void {
1420
+ harness.beforeLedgerInsert = (): void => {
1421
+ harness.beforeLedgerInsert = null;
1422
+ seedLedgerRow(harness, {
1423
+ projectId: PROJECT,
1424
+ userId: USER_A,
1425
+ scheduleId: SCHEDULE,
1426
+ shiftStartsAt: SHIFT_START,
1427
+ minutesBeforeShift: 1440,
1428
+ kind: UserOnCallShiftReminderLogKind.CatchUp,
1429
+ claimedAt,
1430
+ sentAt: claimedAt,
1431
+ });
1432
+ };
1433
+ }
1434
+
1435
+ test("an OLDER claim of a different kind wins: the sweep releases its claim and says nothing", async () => {
1436
+ harness.reminders = [reminder(USER_A, 60)];
1437
+ harness.shifts = [aliceShift()];
1438
+ claimedByAnotherPassAt(minutes(NOW, -1));
1439
+
1440
+ const stats: ShiftReminderSweepStats =
1441
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1442
+
1443
+ expect(stats.sent).toBe(0);
1444
+ expect(stats.claimCollisions).toBe(1);
1445
+ expect(harness.sent).toHaveLength(0);
1446
+ expect(outcomes(harness)).toContain(
1447
+ ShiftReminderOutcome.ClaimCollision,
1448
+ );
1449
+
1450
+ // Only the other pass's row survives; this pass released its own.
1451
+ expect(harness.ledger).toHaveLength(1);
1452
+ expect(harness.ledger[0]!.kind).toBe(
1453
+ UserOnCallShiftReminderLogKind.CatchUp,
1454
+ );
1455
+ });
1456
+
1457
+ test("a NEWER claim does not silence this pass, so exactly one of the two sides backs off", async () => {
1458
+ harness.reminders = [reminder(USER_A, 60)];
1459
+ harness.shifts = [aliceShift()];
1460
+ claimedByAnotherPassAt(minutes(NOW, 1));
1461
+
1462
+ const stats: ShiftReminderSweepStats =
1463
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1464
+
1465
+ expect(stats.sent).toBe(1);
1466
+ expect(harness.sent).toHaveLength(1);
1467
+ expect(harness.ledger).toHaveLength(2);
1468
+ });
1469
+
1470
+ test("rows this pass wrote itself never look like a competitor (several leads due in one tick)", async () => {
1471
+ // Leads 15 and 18 with a start 14 minutes out: both are due now.
1472
+ harness.reminders = [reminder(USER_A, 15), reminder(USER_A, 18)];
1473
+ harness.shifts = [
1474
+ aliceShift({ start: minutes(NOW, 14), end: minutes(NOW, 600) }),
1475
+ ];
1476
+ harness.cache.set(
1477
+ "OnCallShiftReminders:watermark",
1478
+ minutes(NOW, -5).toISOString(),
1479
+ );
1480
+
1481
+ const stats: ShiftReminderSweepStats =
1482
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1483
+
1484
+ expect(stats.sent).toBe(2);
1485
+ expect(stats.claimCollisions).toBe(0);
1486
+ expect(harness.sent).toHaveLength(2);
1487
+ });
1488
+
1489
+ test("a ledger re-read that fails never blocks the reminder", async () => {
1490
+ harness.reminders = [reminder(USER_A, 60)];
1491
+ harness.shifts = [aliceShift()];
1492
+
1493
+ const findBySpy: any = UserOnCallShiftReminderLogService.findBy;
1494
+ const fakeFindBy: any = findBySpy.getMockImplementation();
1495
+ let calls: number = 0;
1496
+
1497
+ findBySpy.mockImplementation(((args: unknown) => {
1498
+ calls++;
1499
+ // The first read is the pass snapshot; the second is the guard.
1500
+ return calls === 2
1501
+ ? Promise.reject(new Error("connection reset"))
1502
+ : fakeFindBy(args);
1503
+ }) as never);
1504
+
1505
+ const stats: ShiftReminderSweepStats =
1506
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1507
+
1508
+ expect(stats.sent).toBe(1);
1509
+ expect(harness.sent).toHaveLength(1);
1510
+ expect(
1511
+ harness.warnings.some((line: string) => {
1512
+ return line.includes("could not re-read the ledger");
1513
+ }),
1514
+ ).toBe(true);
1515
+ });
1516
+ });
1517
+
1518
+ describe("ledger dedup on the minute", () => {
1519
+ test("a start moved by seconds (a re-cut seam) maps onto the existing reminder row", async () => {
1520
+ harness.reminders = [reminder(USER_A, 60)];
1521
+ seedLedgerRow(harness, {
1522
+ projectId: PROJECT,
1523
+ userId: USER_A,
1524
+ scheduleId: SCHEDULE,
1525
+ shiftStartsAt: SHIFT_START,
1526
+ minutesBeforeShift: 60,
1527
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1528
+ claimedAt: minutes(NOW, -5),
1529
+ sentAt: minutes(NOW, -5),
1530
+ });
1531
+ harness.shifts = [
1532
+ aliceShift({ start: new Date(SHIFT_START.getTime() + 30 * 1000) }),
1533
+ ];
1534
+
1535
+ // One minute later, so the 16:00:30 start is inside (15:31, 16:01].
1536
+ const stats: ShiftReminderSweepStats =
1537
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 1) });
1538
+
1539
+ expect(stats.skippedAlreadySent).toBe(1);
1540
+ expect(stats.sent).toBe(0);
1541
+ expect(harness.sent).toHaveLength(0);
1542
+ expect(harness.ledger).toHaveLength(1);
1543
+ });
1544
+
1545
+ test("the claim itself is written with the minute-truncated start", async () => {
1546
+ harness.reminders = [reminder(USER_A, 60)];
1547
+ harness.shifts = [
1548
+ aliceShift({ start: new Date(SHIFT_START.getTime() + 45 * 1000) }),
1549
+ ];
1550
+
1551
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 1) });
1552
+
1553
+ expect(harness.ledger).toHaveLength(1);
1554
+ expect(harness.ledger[0]!.shiftStartsAt.toISOString()).toBe(
1555
+ SHIFT_START.toISOString(),
1556
+ );
1557
+ });
1558
+
1559
+ test("a catch-up already sent for the same lead covers the regular reminder", async () => {
1560
+ harness.reminders = [reminder(USER_A, 60)];
1561
+ harness.shifts = [aliceShift()];
1562
+ seedLedgerRow(harness, {
1563
+ projectId: PROJECT,
1564
+ userId: USER_A,
1565
+ scheduleId: SCHEDULE,
1566
+ shiftStartsAt: SHIFT_START,
1567
+ minutesBeforeShift: 60,
1568
+ kind: UserOnCallShiftReminderLogKind.CatchUp,
1569
+ claimedAt: minutes(NOW, -5),
1570
+ sentAt: minutes(NOW, -5),
1571
+ });
1572
+
1573
+ const stats: ShiftReminderSweepStats =
1574
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1575
+
1576
+ expect(stats.skippedAlreadySent).toBe(1);
1577
+ expect(harness.sent).toHaveLength(0);
1578
+ expect(harness.ledger).toHaveLength(1);
1579
+ });
1580
+
1581
+ test("distinct leads for one shift are distinct reminders, each sent at its own tick", async () => {
1582
+ harness.reminders = [reminder(USER_A, 60), reminder(USER_A, 15)];
1583
+ harness.shifts = [aliceShift()];
1584
+
1585
+ const first: ShiftReminderSweepStats =
1586
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1587
+
1588
+ expect(first.sent).toBe(1);
1589
+ expect(harness.sent[0]!.vars["leadText"]).toBe("1 hour");
1590
+
1591
+ const second: ShiftReminderSweepStats =
1592
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 45) });
1593
+
1594
+ expect(second.sent).toBe(1);
1595
+ expect(harness.sent).toHaveLength(2);
1596
+ expect(harness.sent[1]!.vars["leadText"]).toBe("15 minutes");
1597
+ expect(harness.sent[1]!.smsText).toContain("starts in 15 minutes");
1598
+
1599
+ const leads: Array<number> = ledgerRowsOfKind(
1600
+ harness,
1601
+ UserOnCallShiftReminderLogKind.Reminder,
1602
+ )
1603
+ .map((row: FakeLedgerRow) => {
1604
+ return row.minutesBeforeShift;
1605
+ })
1606
+ .sort((a: number, b: number) => {
1607
+ return a - b;
1608
+ });
1609
+
1610
+ expect(leads).toEqual([15, 60]);
1611
+ });
1612
+
1613
+ test("duplicate lead rows collapse into one reminder", async () => {
1614
+ harness.reminders = [reminder(USER_A, 60), reminder(USER_A, 60)];
1615
+ harness.shifts = [aliceShift()];
1616
+
1617
+ const stats: ShiftReminderSweepStats =
1618
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1619
+
1620
+ expect(stats.sent).toBe(1);
1621
+ expect(harness.ledger).toHaveLength(1);
1622
+ });
1623
+ });
1624
+
1625
+ describe("zero-policy schedules", () => {
1626
+ test("a shift on a schedule attached to no policy is skipped without a claim", async () => {
1627
+ harness.reminders = [reminder(USER_A, 60)];
1628
+ harness.shifts = [aliceShift({ policies: [] })];
1629
+
1630
+ const stats: ShiftReminderSweepStats =
1631
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1632
+
1633
+ expect(stats.skippedNoPolicy).toBe(1);
1634
+ expect(stats.sent).toBe(0);
1635
+ expect(harness.sent).toHaveLength(0);
1636
+ expect(harness.ledger).toHaveLength(0);
1637
+ expect(outcomes(harness)).toContain(
1638
+ ShiftReminderOutcome.SkippedNoPolicy,
1639
+ );
1640
+ });
1641
+
1642
+ test("the zero-policy skip is per shift: another schedule of the same user still reminds", async () => {
1643
+ harness.reminders = [reminder(USER_A, 60)];
1644
+ harness.shifts = [
1645
+ aliceShift({ policies: [] }),
1646
+ aliceShift({ scheduleId: SCHEDULE_2, scheduleName: "Database" }),
1647
+ ];
1648
+
1649
+ const stats: ShiftReminderSweepStats =
1650
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1651
+
1652
+ expect(stats.skippedNoPolicy).toBe(1);
1653
+ expect(stats.sent).toBe(1);
1654
+ expect(harness.sent[0]!.onCallScheduleId).toBe(SCHEDULE_2);
1655
+ });
1656
+ });
1657
+
1658
+ describe("missing UserNotificationSetting row", () => {
1659
+ test("warns once per (user, project, event, day) and counts it, but still hands the send to the settings service", async () => {
1660
+ harness.settings.clear();
1661
+ harness.reminders = [reminder(USER_A, 60), reminder(USER_A, 15)];
1662
+ harness.shifts = [aliceShift()];
1663
+
1664
+ const first: ShiftReminderSweepStats =
1665
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1666
+
1667
+ expect(first.missingSettings).toBe(1);
1668
+ // sendUserNotification itself decides to send nothing without a row.
1669
+ expect(harness.sent).toHaveLength(1);
1670
+
1671
+ const warningsAboutSettings: Array<string> = harness.warnings.filter(
1672
+ (line: string) => {
1673
+ return line.includes("has no UserNotificationSetting row");
1674
+ },
1675
+ );
1676
+
1677
+ expect(warningsAboutSettings).toHaveLength(1);
1678
+ expect(warningsAboutSettings[0]).toContain(USER_A.toString());
1679
+ expect(warningsAboutSettings[0]).toContain(PROJECT.toString());
1680
+ expect(warningsAboutSettings[0]).toContain(
1681
+ "AddShiftReminderNotificationSettingsForUsers",
1682
+ );
1683
+ expect(outcomes(harness)).toContain(
1684
+ ShiftReminderOutcome.MissingSettings,
1685
+ );
1686
+
1687
+ // The 15-minute reminder, same day: counted, not warned again.
1688
+ const second: ShiftReminderSweepStats =
1689
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 45) });
1690
+
1691
+ expect(second.missingSettings).toBe(1);
1692
+ expect(
1693
+ harness.warnings.filter((line: string) => {
1694
+ return line.includes("has no UserNotificationSetting row");
1695
+ }),
1696
+ ).toHaveLength(1);
1697
+ });
1698
+
1699
+ test("warns again on a new day, and after resetMissingSettingsWarnings", async () => {
1700
+ harness.settings.clear();
1701
+ harness.reminders = [reminder(USER_A, 60)];
1702
+ harness.shifts = [
1703
+ aliceShift(),
1704
+ aliceShift({
1705
+ start: minutes(SHIFT_START, 1440),
1706
+ end: minutes(SHIFT_END, 1440),
1707
+ }),
1708
+ ];
1709
+
1710
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1711
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 1440) });
1712
+
1713
+ expect(
1714
+ harness.warnings.filter((line: string) => {
1715
+ return line.includes("has no UserNotificationSetting row");
1716
+ }),
1717
+ ).toHaveLength(2);
1718
+
1719
+ harness.ledger = [];
1720
+ harness.cache.clear();
1721
+ OnCallShiftReminderRunner.resetMissingSettingsWarnings();
1722
+
1723
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1724
+
1725
+ expect(
1726
+ harness.warnings.filter((line: string) => {
1727
+ return line.includes("has no UserNotificationSetting row");
1728
+ }),
1729
+ ).toHaveLength(3);
1730
+ });
1731
+
1732
+ test("a user with the row is never warned about", async () => {
1733
+ harness.reminders = [reminder(USER_A, 60)];
1734
+ harness.shifts = [aliceShift()];
1735
+
1736
+ const stats: ShiftReminderSweepStats =
1737
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1738
+
1739
+ expect(stats.missingSettings).toBe(0);
1740
+ expect(
1741
+ harness.warnings.some((line: string) => {
1742
+ return line.includes("has no UserNotificationSetting row");
1743
+ }),
1744
+ ).toBe(false);
1745
+ });
1746
+ });
1747
+
1748
+ describe("robustness", () => {
1749
+ test("one project's failure is isolated: the other project is still processed, and the watermark is HELD so the failed window is retried", async () => {
1750
+ harness.reminders = [
1751
+ reminder(USER_A, 60, PROJECT),
1752
+ reminder(USER_B, 60, PROJECT_2),
1753
+ ];
1754
+ harness.schedules.set(
1755
+ "schedule-p2",
1756
+ scheduleInfo({
1757
+ scheduleId: "schedule-p2",
1758
+ projectId: PROJECT_2.toString(),
1759
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
1760
+ }),
1761
+ );
1762
+ harness.shifts = [
1763
+ aliceShift(),
1764
+ aliceShift({
1765
+ scheduleId: "schedule-p2",
1766
+ projectId: PROJECT_2.toString(),
1767
+ userId: USER_B.toString(),
1768
+ userName: "Bob Brown",
1769
+ }),
1770
+ ];
1771
+ harness.materializeErrorForSchedules.add(SCHEDULE);
1772
+ harness.cache.set(
1773
+ "OnCallShiftReminders:watermark",
1774
+ minutes(NOW, -5).toISOString(),
1775
+ );
1776
+
1777
+ const stats: ShiftReminderSweepStats =
1778
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1779
+
1780
+ expect(stats.projects).toBe(2);
1781
+ expect(stats.errors).toBe(1);
1782
+ expect(stats.sent).toBe(1);
1783
+ expect(harness.sent[0]!.projectId).toBe(PROJECT_2.toString());
1784
+
1785
+ /*
1786
+ * The window must NOT close over the project that threw: its due
1787
+ * reminders would be lost for good (the next tick starts where this
1788
+ * one ended and isDue never looks back). So the watermark stays at
1789
+ * this tick's lookbackFrom.
1790
+ */
1791
+ expect(stats.watermarkWrittenAt.toISOString()).toBe(
1792
+ minutes(NOW, -5).toISOString(),
1793
+ );
1794
+ expect(watermarkOf(harness)).toBe(minutes(NOW, -5).toISOString());
1795
+ expect(
1796
+ harness.warnings.some((line: string) => {
1797
+ return line.includes("holding the watermark");
1798
+ }),
1799
+ ).toBe(true);
1800
+ });
1801
+
1802
+ test("the next tick re-covers the held window and delivers the reminder the failed project dropped — exactly once", async () => {
1803
+ harness.reminders = [reminder(USER_A, 60)];
1804
+ harness.shifts = [aliceShift()];
1805
+ harness.cache.set(
1806
+ "OnCallShiftReminders:watermark",
1807
+ minutes(NOW, -5).toISOString(),
1808
+ );
1809
+
1810
+ /*
1811
+ * 15:00: the materializer blows up. The lead instant (15:00) is in
1812
+ * this window and would otherwise never be looked at again.
1813
+ */
1814
+ harness.materializeErrorForSchedules.add(SCHEDULE);
1815
+
1816
+ const failed: ShiftReminderSweepStats =
1817
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1818
+
1819
+ expect(failed.errors).toBe(1);
1820
+ expect(failed.sent).toBe(0);
1821
+ expect(watermarkOf(harness)).toBe(minutes(NOW, -5).toISOString());
1822
+
1823
+ // 15:05, healthy again: the same window is swept, and it delivers.
1824
+ harness.materializeErrorForSchedules.clear();
1825
+
1826
+ const recovered: ShiftReminderSweepStats =
1827
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 5) });
1828
+
1829
+ expect(recovered.sent).toBe(1);
1830
+ expect(harness.sent).toHaveLength(1);
1831
+ expect(watermarkOf(harness)).toBe(minutes(NOW, 5).toISOString());
1832
+
1833
+ /*
1834
+ * And re-covering the very same window again (another failure, or a
1835
+ * Redis restart) is a no-op: the ledger row makes it "already sent".
1836
+ */
1837
+ harness.cache.set(
1838
+ "OnCallShiftReminders:watermark",
1839
+ minutes(NOW, -5).toISOString(),
1840
+ );
1841
+
1842
+ const third: ShiftReminderSweepStats =
1843
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 5) });
1844
+
1845
+ expect(third.sent).toBe(0);
1846
+ expect(third.skippedAlreadySent).toBe(1);
1847
+ expect(harness.sent).toHaveLength(1);
1848
+ expect(harness.ledger).toHaveLength(1);
1849
+ });
1850
+
1851
+ test("a clean sweep advances the watermark to now", async () => {
1852
+ harness.reminders = [reminder(USER_A, 60)];
1853
+ harness.shifts = [aliceShift()];
1854
+
1855
+ const stats: ShiftReminderSweepStats =
1856
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1857
+
1858
+ expect(stats.errors).toBe(0);
1859
+ expect(stats.watermarkWrittenAt.toISOString()).toBe(NOW.toISOString());
1860
+ expect(watermarkOf(harness)).toBe(NOW.toISOString());
1861
+ });
1862
+
1863
+ test("one user's failure is isolated inside a project, and holds the watermark", async () => {
1864
+ harness.reminders = [reminder(USER_A, 60), reminder(USER_B, 60)];
1865
+ harness.shifts = [
1866
+ aliceShift(),
1867
+ aliceShift({
1868
+ scheduleId: SCHEDULE_2,
1869
+ userId: USER_B.toString(),
1870
+ userName: "Bob Brown",
1871
+ }),
1872
+ ];
1873
+ harness.cache.set(
1874
+ "OnCallShiftReminders:watermark",
1875
+ minutes(NOW, -5).toISOString(),
1876
+ );
1877
+
1878
+ // Claiming Alice's row blows up (a database hiccup mid-tick).
1879
+ const createSpy: any = UserOnCallShiftReminderLogService.create;
1880
+ const fakeCreate: any = createSpy.getMockImplementation();
1881
+
1882
+ createSpy.mockImplementation(((args: {
1883
+ data: { userId: ObjectID };
1884
+ }) => {
1885
+ if (args.data.userId.toString() === USER_A.toString()) {
1886
+ return Promise.reject(new Error("connection reset"));
1887
+ }
1888
+
1889
+ return fakeCreate(args);
1890
+ }) as never);
1891
+
1892
+ const stats: ShiftReminderSweepStats =
1893
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1894
+
1895
+ // Bob is still reminded; Alice's failure is one counted error.
1896
+ expect(stats.errors).toBe(1);
1897
+ expect(stats.sent).toBe(1);
1898
+ expect(harness.sent).toHaveLength(1);
1899
+ expect(harness.sent[0]!.userId).toBe(USER_B.toString());
1900
+ expect(watermarkOf(harness)).toBe(minutes(NOW, -5).toISOString());
1901
+ });
1902
+
1903
+ test("a failed stamp AFTER a successful send is logged, not thrown: the rest of the user's tick still runs", async () => {
1904
+ harness.reminders = [reminder(USER_A, 60)];
1905
+ // Two shifts, both due for the same lead in this tick.
1906
+ harness.shifts = [aliceShift(), aliceShift({ scheduleId: SCHEDULE_2 })];
1907
+
1908
+ jest
1909
+ .spyOn(UserOnCallShiftReminderLogService, "updateOneById")
1910
+ .mockImplementation(((args: { id: ObjectID }) => {
1911
+ const row: FakeLedgerRow | undefined = harness.ledger.find(
1912
+ (candidate: FakeLedgerRow) => {
1913
+ return candidate.id.toString() === args.id.toString();
1914
+ },
1915
+ );
1916
+
1917
+ if (row && row.onCallDutyPolicyScheduleId.toString() === SCHEDULE) {
1918
+ return Promise.reject(new Error("connection reset"));
1919
+ }
1920
+
1921
+ if (row) {
1922
+ row.sentAt = NOW;
1923
+ }
1924
+
1925
+ return Promise.resolve();
1926
+ }) as never);
1927
+
1928
+ const stats: ShiftReminderSweepStats =
1929
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1930
+
1931
+ // Both went out; neither the second shift nor the tick was aborted.
1932
+ expect(harness.sent).toHaveLength(2);
1933
+ expect(stats.sent).toBe(2);
1934
+ expect(stats.errors).toBe(0);
1935
+ expect(stats.sendFailures).toBe(0);
1936
+ expect(watermarkOf(harness)).toBe(NOW.toISOString());
1937
+
1938
+ expect(
1939
+ harness.errors.some((entry: unknown) => {
1940
+ return String(entry).includes("could not be stamped as sent");
1941
+ }),
1942
+ ).toBe(true);
1943
+ });
1944
+
1945
+ test("an unstamped claim from a failed stamp is not re-sent while it is still fresh", async () => {
1946
+ harness.reminders = [reminder(USER_A, 60)];
1947
+ harness.shifts = [aliceShift()];
1948
+
1949
+ jest
1950
+ .spyOn(UserOnCallShiftReminderLogService, "updateOneById")
1951
+ .mockImplementation((() => {
1952
+ return Promise.reject(new Error("connection reset"));
1953
+ }) as never);
1954
+
1955
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
1956
+
1957
+ expect(harness.sent).toHaveLength(1);
1958
+ expect(harness.ledger).toHaveLength(1);
1959
+ expect(harness.ledger[0]!.sentAt).toBeNull();
1960
+
1961
+ // Re-cover the same window a minute later: in-flight, not re-sent.
1962
+ harness.cache.set(
1963
+ "OnCallShiftReminders:watermark",
1964
+ minutes(NOW, -5).toISOString(),
1965
+ );
1966
+
1967
+ const second: ShiftReminderSweepStats =
1968
+ await OnCallShiftReminderRunner.runSweep({ now: minutes(NOW, 1) });
1969
+
1970
+ expect(second.skippedInFlight).toBe(1);
1971
+ expect(harness.sent).toHaveLength(1);
1972
+ });
1973
+
1974
+ test("when the reminder table cannot be read the sweep throws and the watermark is NOT advanced", async () => {
1975
+ harness.remindersReadError = new Error("database unavailable");
1976
+ harness.cache.set(
1977
+ "OnCallShiftReminders:watermark",
1978
+ minutes(NOW, -5).toISOString(),
1979
+ );
1980
+
1981
+ await expect(
1982
+ OnCallShiftReminderRunner.runSweep({ now: NOW }),
1983
+ ).rejects.toThrow("database unavailable");
1984
+
1985
+ expect(watermarkOf(harness)).toBe(minutes(NOW, -5).toISOString());
1986
+ });
1987
+
1988
+ test("a schedule that hit the simulation cap is warned about and counted, and still reminds", async () => {
1989
+ harness.reminders = [reminder(USER_A, 60)];
1990
+ harness.shifts = [aliceShift()];
1991
+ harness.schedules.set(
1992
+ SCHEDULE,
1993
+ scheduleInfo({
1994
+ scheduleId: SCHEDULE,
1995
+ projectId: PROJECT.toString(),
1996
+ truncated: true,
1997
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
1998
+ }),
1999
+ );
2000
+
2001
+ const stats: ShiftReminderSweepStats =
2002
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
2003
+
2004
+ expect(stats.truncatedSchedules).toBe(1);
2005
+ expect(stats.sent).toBe(1);
2006
+ expect(
2007
+ harness.warnings.some((line: string) => {
2008
+ return line.includes("hit the simulation cap");
2009
+ }),
2010
+ ).toBe(true);
2011
+ });
2012
+
2013
+ test("reminder rows with an unusable lead are ignored", async () => {
2014
+ harness.reminders = [
2015
+ reminder(USER_A, 0),
2016
+ reminder(USER_A, -15),
2017
+ reminder(USER_A, Number.NaN),
2018
+ { projectId: PROJECT, userId: USER_A, minutesBeforeShift: 60 },
2019
+ ];
2020
+ harness.reminders.push({
2021
+ projectId: PROJECT,
2022
+ userId: undefined as unknown as ObjectID,
2023
+ minutesBeforeShift: 60,
2024
+ });
2025
+ harness.shifts = [aliceShift()];
2026
+
2027
+ const stats: ShiftReminderSweepStats =
2028
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
2029
+
2030
+ expect(stats.usersWithReminders).toBe(1);
2031
+ expect(stats.sent).toBe(1);
2032
+ });
2033
+
2034
+ test("a schedule the user is not on this window is materialized but yields nothing", async () => {
2035
+ harness.reminders = [reminder(USER_A, 60)];
2036
+ harness.candidateSchedules.set(USER_A.toString(), [SCHEDULE]);
2037
+ harness.shifts = [
2038
+ aliceShift({ userId: USER_B.toString(), userName: "Bob Brown" }),
2039
+ ];
2040
+
2041
+ const stats: ShiftReminderSweepStats =
2042
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
2043
+
2044
+ expect(stats.shiftsConsidered).toBe(0);
2045
+ expect(harness.sent).toHaveLength(0);
2046
+ });
2047
+
2048
+ test("a user with no candidate schedules costs no materialization", async () => {
2049
+ harness.reminders = [reminder(USER_A, 60)];
2050
+ harness.candidateSchedules.set(USER_A.toString(), []);
2051
+
2052
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
2053
+
2054
+ expect(harness.materializeCalls).toHaveLength(0);
2055
+ });
2056
+
2057
+ test("telemetry failures never break a reminder", async () => {
2058
+ jest.restoreAllMocks();
2059
+ installReminderHarness(harness);
2060
+ jest.spyOn(harness.metrics, "push").mockImplementation((): number => {
2061
+ throw new Error("otel exploded");
2062
+ });
2063
+ harness.reminders = [reminder(USER_A, 60)];
2064
+ harness.shifts = [aliceShift()];
2065
+
2066
+ const stats: ShiftReminderSweepStats =
2067
+ await OnCallShiftReminderRunner.runSweep({ now: NOW });
2068
+
2069
+ expect(stats.sent).toBe(1);
2070
+ expect(stats.errors).toBe(0);
2071
+ });
2072
+ });
2073
+ });
2074
+
2075
+ /*
2076
+ * ---------------------------------------------------------------------
2077
+ * The sweep lock
2078
+ * ---------------------------------------------------------------------
2079
+ */
2080
+
2081
+ describe("runSweepUnderLock", () => {
2082
+ test("takes the cross-replica lock with the job name, a 12-minute timeout and a single attempt", async () => {
2083
+ harness.reminders = [reminder(USER_A, 60)];
2084
+ harness.shifts = [aliceShift()];
2085
+
2086
+ const stats: ShiftReminderSweepStats | null =
2087
+ await OnCallShiftReminderRunner.runSweepUnderLock({ now: NOW });
2088
+
2089
+ expect(stats?.sent).toBe(1);
2090
+ expect(harness.lockCalls).toHaveLength(1);
2091
+ expect(harness.lockCalls[0]).toEqual({
2092
+ key: SHIFT_REMINDER_JOB_NAME,
2093
+ namespace: SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE,
2094
+ lockTimeout: SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS,
2095
+ acquireAttemptsLimit: 1,
2096
+ });
2097
+ expect(SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE).toBe("Workers.Cron");
2098
+ expect(SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS).toBe(12 * 60 * 1000);
2099
+ expect(harness.releasedMutexes).toHaveLength(1);
2100
+ });
2101
+
2102
+ test("skips the tick when the lock is held (or Redis is unavailable), without touching anything", async () => {
2103
+ harness.lockAvailable = false;
2104
+ harness.reminders = [reminder(USER_A, 60)];
2105
+ harness.shifts = [aliceShift()];
2106
+
2107
+ const stats: ShiftReminderSweepStats | null =
2108
+ await OnCallShiftReminderRunner.runSweepUnderLock({ now: NOW });
2109
+
2110
+ expect(stats).toBeNull();
2111
+ expect(harness.materializeCalls).toHaveLength(0);
2112
+ expect(harness.sent).toHaveLength(0);
2113
+ expect(watermarkOf(harness)).toBeUndefined();
2114
+ expect(harness.releasedMutexes).toHaveLength(0);
2115
+ });
2116
+
2117
+ test("releases the lock even when the sweep throws, and lets the error surface", async () => {
2118
+ harness.remindersReadError = new Error("database unavailable");
2119
+
2120
+ await expect(
2121
+ OnCallShiftReminderRunner.runSweepUnderLock({ now: NOW }),
2122
+ ).rejects.toThrow("database unavailable");
2123
+
2124
+ expect(harness.releasedMutexes).toHaveLength(1);
2125
+ });
2126
+
2127
+ test("a failed release is logged, not thrown", async () => {
2128
+ jest.spyOn(Semaphore, "release").mockImplementation((): Promise<void> => {
2129
+ return Promise.reject(new Error("release failed"));
2130
+ });
2131
+
2132
+ const stats: ShiftReminderSweepStats | null =
2133
+ await OnCallShiftReminderRunner.runSweepUnderLock({ now: NOW });
2134
+
2135
+ expect(stats).not.toBeNull();
2136
+ expect(
2137
+ harness.errors.some((entry: unknown) => {
2138
+ return String(entry).includes("releasing the sweep lock");
2139
+ }),
2140
+ ).toBe(true);
2141
+ });
2142
+ });
2143
+
2144
+ /*
2145
+ * ---------------------------------------------------------------------
2146
+ * The change pass
2147
+ * ---------------------------------------------------------------------
2148
+ */
2149
+
2150
+ describe("runChangePass", () => {
2151
+ describe("catch-up", () => {
2152
+ test("a user who now holds a shift starting inside one of their leads, never reminded, gets ONE catch-up", async () => {
2153
+ harness.reminders = [reminder(USER_B, 60)];
2154
+ // Override created 10 minutes before Alice's shift: Bob covers.
2155
+ const now: Date = minutes(SHIFT_START, -10);
2156
+ harness.shifts = [bobCoveringForAlice()];
2157
+
2158
+ const stats: ShiftReminderChangePassStats =
2159
+ await OnCallShiftReminderRunner.runChangePass(
2160
+ changeEvent({ userIds: [USER_B, USER_A] }),
2161
+ { now },
2162
+ );
2163
+
2164
+ expect(stats.catchUpsSent).toBe(1);
2165
+ expect(stats.reassignedSent).toBe(0);
2166
+ expect(stats.errors).toBe(0);
2167
+ expect(stats.skippedReason).toBeNull();
2168
+ expect(stats.projectId).toBe(PROJECT.toString());
2169
+
2170
+ expect(harness.sent).toHaveLength(1);
2171
+ const call: SentNotification = harness.sent[0]!;
2172
+
2173
+ expect(call.userId).toBe(USER_B.toString());
2174
+ expect(call.eventType).toBe(
2175
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
2176
+ );
2177
+ expect(call.smsText).toContain(
2178
+ `Heads up: your on-call shift on Payments for Payments Policy starts in 10 minutes (${NEW_YORK_START_TEXT}) (you are covering for Alice Andersson).`,
2179
+ );
2180
+
2181
+ const rows: Array<FakeLedgerRow> = ledgerRowsOfKind(
2182
+ harness,
2183
+ UserOnCallShiftReminderLogKind.CatchUp,
2184
+ );
2185
+
2186
+ expect(rows).toHaveLength(1);
2187
+ expect(rows[0]!.userId.toString()).toBe(USER_B.toString());
2188
+ expect(rows[0]!.minutesBeforeShift).toBe(60);
2189
+ expect(rows[0]!.sentAt).not.toBeNull();
2190
+ expect(outcomes(harness)).toContain(ShiftReminderOutcome.CatchUpSent);
2191
+ });
2192
+
2193
+ test("the catch-up is keyed with the LARGEST matching lead", async () => {
2194
+ harness.reminders = [
2195
+ reminder(USER_B, 15),
2196
+ reminder(USER_B, 60),
2197
+ reminder(USER_B, 1440),
2198
+ ];
2199
+ harness.shifts = [bobCoveringForAlice()];
2200
+
2201
+ await OnCallShiftReminderRunner.runChangePass(
2202
+ changeEvent({ userIds: [USER_B] }),
2203
+ { now: minutes(SHIFT_START, -10) },
2204
+ );
2205
+
2206
+ const rows: Array<FakeLedgerRow> = ledgerRowsOfKind(
2207
+ harness,
2208
+ UserOnCallShiftReminderLogKind.CatchUp,
2209
+ );
2210
+
2211
+ expect(rows).toHaveLength(1);
2212
+ expect(rows[0]!.minutesBeforeShift).toBe(1440);
2213
+ });
2214
+
2215
+ test("an override created 10 minutes before a shift yields a catch-up when the user has any lead >= 10 minutes", async () => {
2216
+ harness.reminders = [reminder(USER_B, 15)];
2217
+ harness.shifts = [bobCoveringForAlice()];
2218
+
2219
+ const stats: ShiftReminderChangePassStats =
2220
+ await OnCallShiftReminderRunner.runChangePass(
2221
+ changeEvent({ userIds: [USER_B] }),
2222
+ { now: minutes(SHIFT_START, -10) },
2223
+ );
2224
+
2225
+ expect(stats.catchUpsSent).toBe(1);
2226
+ });
2227
+
2228
+ test("no catch-up for a shift that starts beyond every configured lead (the sweep will remind on time)", async () => {
2229
+ harness.reminders = [reminder(USER_B, 60)];
2230
+ harness.shifts = [bobCoveringForAlice()];
2231
+
2232
+ const stats: ShiftReminderChangePassStats =
2233
+ await OnCallShiftReminderRunner.runChangePass(
2234
+ changeEvent({ userIds: [USER_B] }),
2235
+ { now: minutes(SHIFT_START, -120) },
2236
+ );
2237
+
2238
+ expect(stats.catchUpsSent).toBe(0);
2239
+ expect(harness.sent).toHaveLength(0);
2240
+ expect(harness.ledger).toHaveLength(0);
2241
+ });
2242
+
2243
+ test("no catch-up when the user was already told about the shift (any lead, reminder or catch-up)", async () => {
2244
+ harness.reminders = [reminder(USER_B, 60)];
2245
+ harness.shifts = [bobCoveringForAlice()];
2246
+ seedLedgerRow(harness, {
2247
+ projectId: PROJECT,
2248
+ userId: USER_B,
2249
+ scheduleId: SCHEDULE,
2250
+ shiftStartsAt: SHIFT_START,
2251
+ minutesBeforeShift: 15,
2252
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2253
+ claimedAt: minutes(SHIFT_START, -15),
2254
+ sentAt: minutes(SHIFT_START, -15),
2255
+ });
2256
+
2257
+ const stats: ShiftReminderChangePassStats =
2258
+ await OnCallShiftReminderRunner.runChangePass(
2259
+ changeEvent({ userIds: [USER_B] }),
2260
+ { now: minutes(SHIFT_START, -10) },
2261
+ );
2262
+
2263
+ expect(stats.catchUpsSent).toBe(0);
2264
+ expect(harness.sent).toHaveLength(0);
2265
+ });
2266
+
2267
+ test("a second change pass does not repeat the catch-up", async () => {
2268
+ harness.reminders = [reminder(USER_B, 60)];
2269
+ harness.shifts = [bobCoveringForAlice()];
2270
+
2271
+ await OnCallShiftReminderRunner.runChangePass(
2272
+ changeEvent({ userIds: [USER_B] }),
2273
+ { now: minutes(SHIFT_START, -10) },
2274
+ );
2275
+ const second: ShiftReminderChangePassStats =
2276
+ await OnCallShiftReminderRunner.runChangePass(
2277
+ changeEvent({ userIds: [USER_B] }),
2278
+ { now: minutes(SHIFT_START, -8) },
2279
+ );
2280
+
2281
+ expect(second.catchUpsSent).toBe(0);
2282
+ expect(harness.sent).toHaveLength(1);
2283
+ });
2284
+
2285
+ test("no catch-up for a shift on a zero-policy schedule", async () => {
2286
+ harness.reminders = [reminder(USER_B, 60)];
2287
+ harness.shifts = [bobCoveringForAlice({ policies: [] })];
2288
+
2289
+ const stats: ShiftReminderChangePassStats =
2290
+ await OnCallShiftReminderRunner.runChangePass(
2291
+ changeEvent({ userIds: [USER_B] }),
2292
+ { now: minutes(SHIFT_START, -10) },
2293
+ );
2294
+
2295
+ expect(stats.catchUpsSent).toBe(0);
2296
+ expect(harness.ledger).toHaveLength(0);
2297
+ });
2298
+
2299
+ test("no catch-up for a shift that already started", async () => {
2300
+ harness.reminders = [reminder(USER_B, 60)];
2301
+ harness.shifts = [bobCoveringForAlice()];
2302
+
2303
+ const stats: ShiftReminderChangePassStats =
2304
+ await OnCallShiftReminderRunner.runChangePass(
2305
+ changeEvent({ userIds: [USER_B] }),
2306
+ { now: minutes(SHIFT_START, 5) },
2307
+ );
2308
+
2309
+ expect(stats.catchUpsSent).toBe(0);
2310
+ expect(harness.sent).toHaveLength(0);
2311
+ });
2312
+
2313
+ test("a failed catch-up send releases the claim and is counted", async () => {
2314
+ harness.reminders = [reminder(USER_B, 60)];
2315
+ harness.shifts = [bobCoveringForAlice()];
2316
+ harness.failSend = (): Error | null => {
2317
+ return new Error("smtp down");
2318
+ };
2319
+
2320
+ const stats: ShiftReminderChangePassStats =
2321
+ await OnCallShiftReminderRunner.runChangePass(
2322
+ changeEvent({ userIds: [USER_B] }),
2323
+ { now: minutes(SHIFT_START, -10) },
2324
+ );
2325
+
2326
+ expect(stats.sendFailures).toBe(1);
2327
+ expect(stats.catchUpsSent).toBe(0);
2328
+ expect(harness.ledger).toHaveLength(0);
2329
+ });
2330
+
2331
+ test("the regular sweep later honours the catch-up for that lead and still sends the smaller lead", async () => {
2332
+ harness.reminders = [reminder(USER_B, 60), reminder(USER_B, 15)];
2333
+ harness.shifts = [bobCoveringForAlice()];
2334
+
2335
+ // Override at T-30: catch-up keyed 60.
2336
+ await OnCallShiftReminderRunner.runChangePass(
2337
+ changeEvent({ userIds: [USER_B] }),
2338
+ { now: minutes(SHIFT_START, -30) },
2339
+ );
2340
+ expect(harness.sent).toHaveLength(1);
2341
+
2342
+ /*
2343
+ * Sweep at T-28 with a watermark from T-33: lead 60's instant (T-60)
2344
+ * is behind the watermark, nothing due yet.
2345
+ */
2346
+ harness.cache.set(
2347
+ "OnCallShiftReminders:watermark",
2348
+ minutes(SHIFT_START, -33).toISOString(),
2349
+ );
2350
+ const early: ShiftReminderSweepStats =
2351
+ await OnCallShiftReminderRunner.runSweep({
2352
+ now: minutes(SHIFT_START, -28),
2353
+ });
2354
+ expect(early.sent).toBe(0);
2355
+
2356
+ // Sweep at T-13: the 15-minute reminder is due and is a different key.
2357
+ const later: ShiftReminderSweepStats =
2358
+ await OnCallShiftReminderRunner.runSweep({
2359
+ now: minutes(SHIFT_START, -13),
2360
+ });
2361
+
2362
+ expect(later.sent).toBe(1);
2363
+ expect(harness.sent).toHaveLength(2);
2364
+ expect(harness.sent[1]!.vars["leadText"]).toBe("15 minutes");
2365
+ });
2366
+
2367
+ test("a catch-up sent INSIDE a smaller lead's window suppresses that lead's near-identical reminder", async () => {
2368
+ harness.reminders = [reminder(USER_B, 60), reminder(USER_B, 15)];
2369
+ harness.shifts = [bobCoveringForAlice()];
2370
+
2371
+ /*
2372
+ * Override at T-17. The catch-up is keyed with the largest matching
2373
+ * lead (60) and says "starts in 17 minutes" — it IS the 15-minute
2374
+ * reminder, four minutes early.
2375
+ */
2376
+ await OnCallShiftReminderRunner.runChangePass(
2377
+ changeEvent({ userIds: [USER_B] }),
2378
+ { now: minutes(SHIFT_START, -17) },
2379
+ );
2380
+
2381
+ expect(harness.sent).toHaveLength(1);
2382
+ expect(harness.sent[0]!.smsText).toContain("starts in 17 minutes");
2383
+
2384
+ // Sweep at T-14: lead 15 is due, and must NOT repeat it.
2385
+ harness.cache.set(
2386
+ "OnCallShiftReminders:watermark",
2387
+ minutes(SHIFT_START, -19).toISOString(),
2388
+ );
2389
+
2390
+ const stats: ShiftReminderSweepStats =
2391
+ await OnCallShiftReminderRunner.runSweep({
2392
+ now: minutes(SHIFT_START, -14),
2393
+ });
2394
+
2395
+ expect(stats.sent).toBe(0);
2396
+ expect(stats.skippedAlreadySent).toBe(1);
2397
+ expect(harness.sent).toHaveLength(1);
2398
+ expect(outcomes(harness)).toContain(
2399
+ ShiftReminderOutcome.SkippedAlreadySent,
2400
+ );
2401
+ });
2402
+
2403
+ test("a catch-up sent well before a smaller lead's window does not suppress it", async () => {
2404
+ harness.reminders = [reminder(USER_B, 60), reminder(USER_B, 15)];
2405
+ harness.shifts = [bobCoveringForAlice()];
2406
+
2407
+ // T-40 is far outside the 15-minute lead (+ 5 minutes of tolerance).
2408
+ await OnCallShiftReminderRunner.runChangePass(
2409
+ changeEvent({ userIds: [USER_B] }),
2410
+ { now: minutes(SHIFT_START, -40) },
2411
+ );
2412
+
2413
+ harness.cache.set(
2414
+ "OnCallShiftReminders:watermark",
2415
+ minutes(SHIFT_START, -19).toISOString(),
2416
+ );
2417
+
2418
+ const stats: ShiftReminderSweepStats =
2419
+ await OnCallShiftReminderRunner.runSweep({
2420
+ now: minutes(SHIFT_START, -14),
2421
+ });
2422
+
2423
+ expect(stats.sent).toBe(1);
2424
+ expect(harness.sent).toHaveLength(2);
2425
+ expect(harness.sent[1]!.vars["leadText"]).toBe("15 minutes");
2426
+ });
2427
+ });
2428
+
2429
+ describe("reassigned", () => {
2430
+ function aliceWasReminded(claimedAt: Date): FakeLedgerRow {
2431
+ return seedLedgerRow(harness, {
2432
+ projectId: PROJECT,
2433
+ userId: USER_A,
2434
+ scheduleId: SCHEDULE,
2435
+ shiftStartsAt: SHIFT_START,
2436
+ minutesBeforeShift: 60,
2437
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2438
+ claimedAt,
2439
+ sentAt: claimedAt,
2440
+ });
2441
+ }
2442
+
2443
+ test("a user reminded about a shift they no longer hold gets ONE 'reassigned' notice naming the replacement", async () => {
2444
+ aliceWasReminded(minutes(SHIFT_START, -60));
2445
+ // Bob now covers; the hook named only Bob (Alice is found via her ledger row).
2446
+ harness.shifts = [bobCoveringForAlice()];
2447
+
2448
+ const now: Date = minutes(SHIFT_START, -40);
2449
+ const stats: ShiftReminderChangePassStats =
2450
+ await OnCallShiftReminderRunner.runChangePass(
2451
+ changeEvent({ userIds: [USER_B] }),
2452
+ { now },
2453
+ );
2454
+
2455
+ expect(stats.reassignedSent).toBe(1);
2456
+ expect(stats.catchUpsSent).toBe(0);
2457
+ expect(stats.users).toBe(2);
2458
+
2459
+ expect(harness.sent).toHaveLength(1);
2460
+ const call: SentNotification = harness.sent[0]!;
2461
+
2462
+ expect(call.userId).toBe(USER_A.toString());
2463
+ expect(call.eventType).toBe(
2464
+ NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
2465
+ );
2466
+ expect(call.templateType).toBe(
2467
+ EmailTemplateType.UserOnCallShiftReassigned,
2468
+ );
2469
+ expect(call.smsText).toContain(
2470
+ `Your on-call shift on Payments at ${BERLIN_START_TEXT} is now covered by Bob Brown.`,
2471
+ );
2472
+ expect(call.onCallScheduleId).toBe(SCHEDULE);
2473
+
2474
+ const rows: Array<FakeLedgerRow> = ledgerRowsOfKind(
2475
+ harness,
2476
+ UserOnCallShiftReminderLogKind.Reassigned,
2477
+ );
2478
+
2479
+ expect(rows).toHaveLength(1);
2480
+ expect(rows[0]!.minutesBeforeShift).toBe(0);
2481
+ expect(rows[0]!.userId.toString()).toBe(USER_A.toString());
2482
+ expect(rows[0]!.sentAt).not.toBeNull();
2483
+ expect(outcomes(harness)).toContain(
2484
+ ShiftReminderOutcome.ReassignedSent,
2485
+ );
2486
+ });
2487
+
2488
+ test("says 'no longer assigned to you' when nobody holds the shift now", async () => {
2489
+ aliceWasReminded(minutes(SHIFT_START, -60));
2490
+ harness.shifts = [];
2491
+
2492
+ const stats: ShiftReminderChangePassStats =
2493
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2494
+ now: minutes(SHIFT_START, -40),
2495
+ });
2496
+
2497
+ expect(stats.reassignedSent).toBe(1);
2498
+ expect(harness.sent[0]!.smsText).toContain(
2499
+ "is no longer assigned to you.",
2500
+ );
2501
+ });
2502
+
2503
+ test("a policy-variant shift is never reported as the replacement", async () => {
2504
+ aliceWasReminded(minutes(SHIFT_START, -60));
2505
+ harness.shifts = [
2506
+ bobCoveringForAlice({
2507
+ policyVariantOf: {
2508
+ policyId: "pol-2",
2509
+ policyName: "Other",
2510
+ globalUserId: USER_A.toString(),
2511
+ },
2512
+ }),
2513
+ ];
2514
+
2515
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2516
+ now: minutes(SHIFT_START, -40),
2517
+ });
2518
+
2519
+ expect(harness.sent).toHaveLength(1);
2520
+ expect(harness.sent[0]!.smsText).toContain(
2521
+ "is no longer assigned to you.",
2522
+ );
2523
+ });
2524
+
2525
+ test("one notice per shift however many leads were reminded, and never twice", async () => {
2526
+ aliceWasReminded(minutes(SHIFT_START, -60));
2527
+ seedLedgerRow(harness, {
2528
+ projectId: PROJECT,
2529
+ userId: USER_A,
2530
+ scheduleId: SCHEDULE,
2531
+ shiftStartsAt: SHIFT_START,
2532
+ minutesBeforeShift: 1440,
2533
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2534
+ claimedAt: minutes(SHIFT_START, -1440),
2535
+ sentAt: minutes(SHIFT_START, -1440),
2536
+ });
2537
+ harness.shifts = [bobCoveringForAlice()];
2538
+
2539
+ const first: ShiftReminderChangePassStats =
2540
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2541
+ now: minutes(SHIFT_START, -40),
2542
+ });
2543
+ const second: ShiftReminderChangePassStats =
2544
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2545
+ now: minutes(SHIFT_START, -35),
2546
+ });
2547
+
2548
+ expect(first.reassignedSent).toBe(1);
2549
+ expect(second.reassignedSent).toBe(0);
2550
+ expect(harness.sent).toHaveLength(1);
2551
+ });
2552
+
2553
+ test("a user who still holds the shift gets no notice", async () => {
2554
+ aliceWasReminded(minutes(SHIFT_START, -60));
2555
+ harness.shifts = [aliceShift()];
2556
+
2557
+ const stats: ShiftReminderChangePassStats =
2558
+ await OnCallShiftReminderRunner.runChangePass(
2559
+ changeEvent({ userIds: [USER_A] }),
2560
+ { now: minutes(SHIFT_START, -40) },
2561
+ );
2562
+
2563
+ expect(stats.reassignedSent).toBe(0);
2564
+ expect(harness.sent).toHaveLength(0);
2565
+ });
2566
+
2567
+ test("a shift that already started is not reported", async () => {
2568
+ aliceWasReminded(minutes(SHIFT_START, -60));
2569
+ harness.shifts = [bobCoveringForAlice()];
2570
+
2571
+ const stats: ShiftReminderChangePassStats =
2572
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2573
+ now: minutes(SHIFT_START, 5),
2574
+ });
2575
+
2576
+ expect(stats.reassignedSent).toBe(0);
2577
+ });
2578
+
2579
+ test("a schedule that hit the simulation cap cannot be judged: no notice", async () => {
2580
+ aliceWasReminded(minutes(SHIFT_START, -60));
2581
+ harness.shifts = [bobCoveringForAlice()];
2582
+ harness.schedules.set(
2583
+ SCHEDULE,
2584
+ scheduleInfo({
2585
+ scheduleId: SCHEDULE,
2586
+ projectId: PROJECT.toString(),
2587
+ truncated: true,
2588
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
2589
+ }),
2590
+ );
2591
+
2592
+ const stats: ShiftReminderChangePassStats =
2593
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2594
+ now: minutes(SHIFT_START, -40),
2595
+ });
2596
+
2597
+ expect(stats.reassignedSent).toBe(0);
2598
+ expect(harness.sent).toHaveLength(0);
2599
+ });
2600
+
2601
+ test("taken back: reminder -> reassigned -> holds again yields a catch-up and clears the notice, so a later flip can notify again", async () => {
2602
+ harness.reminders = [reminder(USER_A, 60)];
2603
+ aliceWasReminded(minutes(SHIFT_START, -60));
2604
+
2605
+ // 1. Override: Bob covers -> Alice is told.
2606
+ harness.shifts = [bobCoveringForAlice()];
2607
+ const first: ShiftReminderChangePassStats =
2608
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2609
+ now: minutes(SHIFT_START, -40),
2610
+ });
2611
+ expect(first.reassignedSent).toBe(1);
2612
+
2613
+ // 2. Override deleted: Alice holds again -> catch-up, notice row gone.
2614
+ harness.shifts = [aliceShift()];
2615
+ const second: ShiftReminderChangePassStats =
2616
+ await OnCallShiftReminderRunner.runChangePass(
2617
+ changeEvent({ userIds: [USER_A] }),
2618
+ { now: minutes(SHIFT_START, -30) },
2619
+ );
2620
+ expect(second.catchUpsSent).toBe(1);
2621
+ expect(second.reassignedSent).toBe(0);
2622
+ expect(harness.sent[1]!.userId).toBe(USER_A.toString());
2623
+ expect(harness.sent[1]!.smsText).toContain(
2624
+ "Heads up: your on-call shift on Payments for Payments Policy starts in 30 minutes",
2625
+ );
2626
+ expect(
2627
+ ledgerRowsOfKind(harness, UserOnCallShiftReminderLogKind.Reassigned),
2628
+ ).toHaveLength(0);
2629
+
2630
+ // 3. Override again: a fresh notice.
2631
+ harness.shifts = [bobCoveringForAlice()];
2632
+ const third: ShiftReminderChangePassStats =
2633
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2634
+ now: minutes(SHIFT_START, -20),
2635
+ });
2636
+ expect(third.reassignedSent).toBe(1);
2637
+ expect(harness.sent).toHaveLength(3);
2638
+ });
2639
+
2640
+ test("the notice is delivered in the recipient's timezone with the schedule's policies for routing", async () => {
2641
+ aliceWasReminded(minutes(SHIFT_START, -60));
2642
+ harness.shifts = [bobCoveringForAlice()];
2643
+
2644
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2645
+ now: minutes(SHIFT_START, -40),
2646
+ });
2647
+
2648
+ expect(harness.sent[0]!.vars["startsAt"]).toBe(BERLIN_START_TEXT);
2649
+ expect(harness.sent[0]!.onCallPolicyId).toBe(DEFAULT_POLICY.policyId);
2650
+ // Everyone was in the materialization: no extra user lookup.
2651
+ expect(harness.userLookupCalls).toHaveLength(0);
2652
+ });
2653
+
2654
+ test("a user REMOVED from the layer still gets the notice in their own timezone, not the schedule's", async () => {
2655
+ /*
2656
+ * The layer-user row is already gone when the change pass runs (the
2657
+ * removal hook fires after the delete, and the member-left cleanup
2658
+ * deletes the rows first), so the materializer — which only looks up
2659
+ * segment holders, override parties and current members — never
2660
+ * returns Alice at all.
2661
+ */
2662
+ harness.schedules.set(
2663
+ SCHEDULE,
2664
+ scheduleInfo({
2665
+ scheduleId: SCHEDULE,
2666
+ scheduleName: "Payments",
2667
+ projectId: PROJECT.toString(),
2668
+ scheduleTimezone: "America/New_York",
2669
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
2670
+ }),
2671
+ );
2672
+ harness.users = harness.users.filter((user: { userId: string }) => {
2673
+ return user.userId !== USER_A.toString();
2674
+ });
2675
+ harness.directory = [
2676
+ {
2677
+ userId: USER_A.toString(),
2678
+ userName: "Alice Andersson",
2679
+ email: "alice@example.com",
2680
+ timezone: "Europe/Berlin",
2681
+ },
2682
+ ];
2683
+
2684
+ aliceWasReminded(minutes(SHIFT_START, -60));
2685
+ harness.shifts = [bobCoveringForAlice()];
2686
+
2687
+ const stats: ShiftReminderChangePassStats =
2688
+ await OnCallShiftReminderRunner.runChangePass(
2689
+ changeEvent({
2690
+ userIds: [USER_A],
2691
+ reason: OnCallShiftChangeReason.LayerUserChanged,
2692
+ }),
2693
+ { now: minutes(SHIFT_START, -40) },
2694
+ );
2695
+
2696
+ expect(stats.reassignedSent).toBe(1);
2697
+ expect(harness.userLookupCalls).toHaveLength(1);
2698
+ expect(harness.sent[0]!.userId).toBe(USER_A.toString());
2699
+ expect(harness.sent[0]!.vars["startsAt"]).toBe(BERLIN_START_TEXT);
2700
+ expect(harness.sent[0]!.vars["timezone"]).toBe("Europe/Berlin");
2701
+ expect(harness.sent[0]!.smsText).not.toContain("America/New_York");
2702
+ });
2703
+
2704
+ test("a recipient the directory cannot resolve either still gets the notice, in the schedule's timezone", async () => {
2705
+ harness.schedules.set(
2706
+ SCHEDULE,
2707
+ scheduleInfo({
2708
+ scheduleId: SCHEDULE,
2709
+ scheduleName: "Payments",
2710
+ projectId: PROJECT.toString(),
2711
+ scheduleTimezone: "America/New_York",
2712
+ attachedPolicies: [{ ...DEFAULT_POLICY }],
2713
+ }),
2714
+ );
2715
+ harness.users = harness.users.filter((user: { userId: string }) => {
2716
+ return user.userId !== USER_A.toString();
2717
+ });
2718
+ harness.directory = [];
2719
+
2720
+ aliceWasReminded(minutes(SHIFT_START, -60));
2721
+ harness.shifts = [bobCoveringForAlice()];
2722
+
2723
+ const stats: ShiftReminderChangePassStats =
2724
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2725
+ now: minutes(SHIFT_START, -40),
2726
+ });
2727
+
2728
+ expect(stats.reassignedSent).toBe(1);
2729
+ expect(harness.sent[0]!.vars["startsAt"]).toBe(NEW_YORK_START_TEXT);
2730
+ });
2731
+
2732
+ test("a failed directory lookup never blocks the notice", async () => {
2733
+ harness.users = harness.users.filter((user: { userId: string }) => {
2734
+ return user.userId !== USER_A.toString();
2735
+ });
2736
+
2737
+ jest.spyOn(UserService, "findBy").mockImplementation((() => {
2738
+ return Promise.reject(new Error("database unavailable"));
2739
+ }) as never);
2740
+
2741
+ aliceWasReminded(minutes(SHIFT_START, -60));
2742
+ harness.shifts = [bobCoveringForAlice()];
2743
+
2744
+ const stats: ShiftReminderChangePassStats =
2745
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2746
+ now: minutes(SHIFT_START, -40),
2747
+ });
2748
+
2749
+ expect(stats.reassignedSent).toBe(1);
2750
+ expect(stats.errors).toBe(0);
2751
+ expect(
2752
+ harness.warnings.some((line: string) => {
2753
+ return line.includes("notice recipient");
2754
+ }),
2755
+ ).toBe(true);
2756
+ });
2757
+
2758
+ test("a failed notice send releases the claim so the next pass retries", async () => {
2759
+ aliceWasReminded(minutes(SHIFT_START, -60));
2760
+ harness.shifts = [bobCoveringForAlice()];
2761
+ let fail: boolean = true;
2762
+ harness.failSend = (): Error | null => {
2763
+ return fail ? new Error("smtp down") : null;
2764
+ };
2765
+
2766
+ const first: ShiftReminderChangePassStats =
2767
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2768
+ now: minutes(SHIFT_START, -40),
2769
+ });
2770
+
2771
+ expect(first.sendFailures).toBe(1);
2772
+ expect(
2773
+ ledgerRowsOfKind(harness, UserOnCallShiftReminderLogKind.Reassigned),
2774
+ ).toHaveLength(0);
2775
+
2776
+ fail = false;
2777
+ const second: ShiftReminderChangePassStats =
2778
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2779
+ now: minutes(SHIFT_START, -38),
2780
+ });
2781
+
2782
+ expect(second.reassignedSent).toBe(1);
2783
+ });
2784
+ });
2785
+
2786
+ describe("scope and robustness", () => {
2787
+ test("resolves the project from the schedules when the event carries none", async () => {
2788
+ harness.reminders = [reminder(USER_B, 60)];
2789
+ harness.shifts = [bobCoveringForAlice()];
2790
+
2791
+ const stats: ShiftReminderChangePassStats =
2792
+ await OnCallShiftReminderRunner.runChangePass(
2793
+ changeEvent({ projectId: null, userIds: [USER_B] }),
2794
+ { now: minutes(SHIFT_START, -10) },
2795
+ );
2796
+
2797
+ expect(stats.projectId).toBe(PROJECT.toString());
2798
+ expect(stats.catchUpsSent).toBe(1);
2799
+ });
2800
+
2801
+ test("ends early with 'no-project' when neither the event nor its schedules name one", async () => {
2802
+ harness.scheduleProjects.clear();
2803
+
2804
+ const stats: ShiftReminderChangePassStats =
2805
+ await OnCallShiftReminderRunner.runChangePass(
2806
+ changeEvent({ projectId: null, scheduleIds: ["unknown-schedule"] }),
2807
+ { now: NOW },
2808
+ );
2809
+
2810
+ expect(stats.skippedReason).toBe("no-project");
2811
+ expect(harness.materializeCalls).toHaveLength(0);
2812
+ });
2813
+
2814
+ test("ends early with 'no-users' when nobody is named and no ledger row points at the schedules", async () => {
2815
+ const stats: ShiftReminderChangePassStats =
2816
+ await OnCallShiftReminderRunner.runChangePass(changeEvent({}), {
2817
+ now: NOW,
2818
+ });
2819
+
2820
+ expect(stats.skippedReason).toBe("no-users");
2821
+ expect(harness.materializeCalls).toHaveLength(0);
2822
+ });
2823
+
2824
+ test("ends early with 'nothing-to-do' when the named users have no leads and no future ledger rows", async () => {
2825
+ const stats: ShiftReminderChangePassStats =
2826
+ await OnCallShiftReminderRunner.runChangePass(
2827
+ changeEvent({ userIds: [USER_C] }),
2828
+ { now: NOW },
2829
+ );
2830
+
2831
+ expect(stats.skippedReason).toBe("nothing-to-do");
2832
+ expect(harness.materializeCalls).toHaveLength(0);
2833
+ });
2834
+
2835
+ test("ends early with 'no-schedules' when there is nothing to materialize", async () => {
2836
+ harness.reminders = [reminder(USER_B, 60)];
2837
+ harness.candidateSchedules.set(USER_B.toString(), []);
2838
+
2839
+ const stats: ShiftReminderChangePassStats =
2840
+ await OnCallShiftReminderRunner.runChangePass(
2841
+ changeEvent({ scheduleIds: [], userIds: [USER_B] }),
2842
+ { now: NOW },
2843
+ );
2844
+
2845
+ expect(stats.skippedReason).toBe("no-schedules");
2846
+ });
2847
+
2848
+ test("materializes the named schedules plus every schedule the reminded users can hold shifts on, to the farthest lead or ledger row", async () => {
2849
+ harness.reminders = [reminder(USER_B, 60)];
2850
+ harness.candidateSchedules.set(USER_B.toString(), [SCHEDULE_2]);
2851
+ // Alice has a reminder row for a shift 3 hours out on SCHEDULE.
2852
+ seedLedgerRow(harness, {
2853
+ projectId: PROJECT,
2854
+ userId: USER_A,
2855
+ scheduleId: SCHEDULE,
2856
+ shiftStartsAt: minutes(NOW, 180),
2857
+ minutesBeforeShift: 1440,
2858
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2859
+ claimedAt: minutes(NOW, -1),
2860
+ sentAt: minutes(NOW, -1),
2861
+ });
2862
+
2863
+ await OnCallShiftReminderRunner.runChangePass(
2864
+ changeEvent({ scheduleIds: [SCHEDULE], userIds: [USER_B] }),
2865
+ { now: NOW },
2866
+ );
2867
+
2868
+ expect(harness.materializeCalls).toHaveLength(1);
2869
+ expect(harness.materializeCalls[0]!.scheduleIds.sort()).toEqual([
2870
+ SCHEDULE,
2871
+ SCHEDULE_2,
2872
+ ]);
2873
+ expect(harness.materializeCalls[0]!.windowStart.toISOString()).toBe(
2874
+ NOW.toISOString(),
2875
+ );
2876
+ expect(harness.materializeCalls[0]!.windowEnd.toISOString()).toBe(
2877
+ minutes(
2878
+ NOW,
2879
+ 180 + SHIFT_REMINDER_WINDOW_PADDING_MINUTES,
2880
+ ).toISOString(),
2881
+ );
2882
+ });
2883
+
2884
+ test("never throws: a materializer failure is counted and logged", async () => {
2885
+ harness.reminders = [reminder(USER_B, 60)];
2886
+ harness.shifts = [bobCoveringForAlice()];
2887
+ harness.materializeErrorForSchedules.add(SCHEDULE);
2888
+
2889
+ const stats: ShiftReminderChangePassStats =
2890
+ await OnCallShiftReminderRunner.runChangePass(
2891
+ changeEvent({ userIds: [USER_B] }),
2892
+ { now: minutes(SHIFT_START, -10) },
2893
+ );
2894
+
2895
+ expect(stats.errors).toBe(1);
2896
+ expect(harness.sent).toHaveLength(0);
2897
+ expect(
2898
+ harness.errors.some((entry: unknown) => {
2899
+ return String(entry).includes("change pass failed");
2900
+ }),
2901
+ ).toBe(true);
2902
+ });
2903
+
2904
+ test("one user's failure does not stop the others", async () => {
2905
+ harness.reminders = [reminder(USER_B, 60), reminder(USER_C, 60)];
2906
+ harness.shifts = [
2907
+ bobCoveringForAlice(),
2908
+ aliceShift({
2909
+ scheduleId: SCHEDULE_2,
2910
+ userId: USER_C.toString(),
2911
+ userName: "Carol Chen",
2912
+ }),
2913
+ ];
2914
+ harness.failSend = (call: SentNotification): Error | null => {
2915
+ if (call.userId === USER_B.toString()) {
2916
+ throw new TypeError("unexpected");
2917
+ }
2918
+ return null;
2919
+ };
2920
+
2921
+ const stats: ShiftReminderChangePassStats =
2922
+ await OnCallShiftReminderRunner.runChangePass(
2923
+ changeEvent({
2924
+ scheduleIds: [SCHEDULE, SCHEDULE_2],
2925
+ userIds: [USER_B, USER_C],
2926
+ }),
2927
+ { now: minutes(SHIFT_START, -10) },
2928
+ );
2929
+
2930
+ expect(stats.catchUpsSent).toBe(1);
2931
+ expect(harness.sent[0]!.userId).toBe(USER_C.toString());
2932
+ });
2933
+
2934
+ test("the reminder lead times are read for exactly the users in scope, as root", async () => {
2935
+ harness.reminders = [reminder(USER_B, 60)];
2936
+ harness.shifts = [bobCoveringForAlice()];
2937
+
2938
+ const findBy: any = jest.spyOn(
2939
+ UserOnCallShiftReminderLogService,
2940
+ "findBy",
2941
+ );
2942
+
2943
+ await OnCallShiftReminderRunner.runChangePass(
2944
+ changeEvent({ userIds: [USER_B] }),
2945
+ { now: minutes(SHIFT_START, -10) },
2946
+ );
2947
+
2948
+ for (const call of findBy.mock.calls) {
2949
+ const args: { props: { isRoot: boolean } } = call[0] as unknown as {
2950
+ props: { isRoot: boolean };
2951
+ };
2952
+ expect(args.props.isRoot).toBe(true);
2953
+ }
2954
+ });
2955
+ });
2956
+ });
2957
+
2958
+ /*
2959
+ * ---------------------------------------------------------------------
2960
+ * Retention
2961
+ * ---------------------------------------------------------------------
2962
+ */
2963
+
2964
+ describe("deleteOldLogs", () => {
2965
+ test("deletes rows whose SHIFT started more than 30 days ago, in batches, until none remain", async () => {
2966
+ for (let index: number = 0; index < 5; index++) {
2967
+ seedLedgerRow(harness, {
2968
+ projectId: PROJECT,
2969
+ userId: USER_A,
2970
+ scheduleId: SCHEDULE,
2971
+ shiftStartsAt: OneUptimeDate.addRemoveDays(NOW, -31 - index),
2972
+ minutesBeforeShift: 60,
2973
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2974
+ claimedAt: OneUptimeDate.addRemoveDays(NOW, -32 - index),
2975
+ sentAt: OneUptimeDate.addRemoveDays(NOW, -32 - index),
2976
+ });
2977
+ }
2978
+ // Claimed long ago but the shift is only 10 days old: kept.
2979
+ seedLedgerRow(harness, {
2980
+ projectId: PROJECT,
2981
+ userId: USER_A,
2982
+ scheduleId: SCHEDULE,
2983
+ shiftStartsAt: OneUptimeDate.addRemoveDays(NOW, -10),
2984
+ minutesBeforeShift: 60,
2985
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2986
+ claimedAt: OneUptimeDate.addRemoveDays(NOW, -40),
2987
+ sentAt: null,
2988
+ });
2989
+ // A future shift: kept.
2990
+ seedLedgerRow(harness, {
2991
+ projectId: PROJECT,
2992
+ userId: USER_A,
2993
+ scheduleId: SCHEDULE,
2994
+ shiftStartsAt: SHIFT_START,
2995
+ minutesBeforeShift: 60,
2996
+ kind: UserOnCallShiftReminderLogKind.Reminder,
2997
+ claimedAt: NOW,
2998
+ sentAt: NOW,
2999
+ });
3000
+
3001
+ const deleteBy: any = jest.spyOn(
3002
+ UserOnCallShiftReminderLogService,
3003
+ "deleteBy",
3004
+ );
3005
+
3006
+ const result: ShiftReminderRetentionStats =
3007
+ await OnCallShiftReminderRunner.deleteOldLogs({
3008
+ now: NOW,
3009
+ batchSize: 2,
3010
+ });
3011
+
3012
+ expect(result.deleted).toBe(5);
3013
+ expect(result.cutoff.toISOString()).toBe(
3014
+ OneUptimeDate.addRemoveDays(
3015
+ NOW,
3016
+ -SHIFT_REMINDER_LOG_RETENTION_DAYS,
3017
+ ).toISOString(),
3018
+ );
3019
+ expect(harness.ledger).toHaveLength(2);
3020
+ // 2 + 2 + 1, then the empty batch that ends the loop.
3021
+ expect(deleteBy).toHaveBeenCalledTimes(4);
3022
+
3023
+ for (const call of deleteBy.mock.calls) {
3024
+ const args: {
3025
+ query: Record<string, unknown>;
3026
+ limit: number;
3027
+ props: { isRoot: boolean };
3028
+ } = call[0] as unknown as {
3029
+ query: Record<string, unknown>;
3030
+ limit: number;
3031
+ props: { isRoot: boolean };
3032
+ };
3033
+
3034
+ expect(args.query["shiftStartsAt"]).toBeDefined();
3035
+ expect(args.limit).toBe(2);
3036
+ expect(args.props.isRoot).toBe(true);
3037
+ }
3038
+ });
3039
+
3040
+ test("defaults: 30 days, batches of 100, the current time", async () => {
3041
+ const deleteBy: any = jest.spyOn(
3042
+ UserOnCallShiftReminderLogService,
3043
+ "deleteBy",
3044
+ );
3045
+
3046
+ const result: ShiftReminderRetentionStats =
3047
+ await OnCallShiftReminderRunner.deleteOldLogs();
3048
+
3049
+ expect(SHIFT_REMINDER_LOG_RETENTION_DAYS).toBe(30);
3050
+ expect(result.deleted).toBe(0);
3051
+ expect(
3052
+ Math.abs(
3053
+ result.cutoff.getTime() -
3054
+ OneUptimeDate.addRemoveDays(new Date(), -30).getTime(),
3055
+ ),
3056
+ ).toBeLessThan(5000);
3057
+ expect(
3058
+ (deleteBy.mock.calls[0]![0] as unknown as { limit: number }).limit,
3059
+ ).toBe(SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE);
3060
+ });
3061
+ });
3062
+ });
3063
+
3064
+ describe("OnCallShiftReminderRunner constants", () => {
3065
+ test("names and timings match the jobs and the spec", () => {
3066
+ expect(SHIFT_REMINDER_JOB_NAME).toBe(
3067
+ "OnCallDutySchedule:SendShiftReminders",
3068
+ );
3069
+ expect(SHIFT_REMINDER_MAX_LOOKBACK_MINUTES).toBe(30);
3070
+ expect(SHIFT_REMINDER_RECLAIM_AFTER_MINUTES).toBe(10);
3071
+ expect(SHIFT_REMINDER_WINDOW_PADDING_MINUTES).toBe(30);
3072
+ expect(SHIFT_REMINDER_LOG_RETENTION_DAYS).toBe(30);
3073
+ });
3074
+
3075
+ test("the materializer is the only source of shifts (no direct engine access)", () => {
3076
+ // A sanity check that the runner goes through B's materializer API.
3077
+ expect(typeof OnCallShiftMaterializer.materializeForSchedules).toBe(
3078
+ "function",
3079
+ );
3080
+ expect(typeof OnCallShiftMaterializer.getCandidateScheduleIdsForUser).toBe(
3081
+ "function",
3082
+ );
3083
+ // And the batched lookup the sweep uses (one call per project, not per user).
3084
+ expect(typeof OnCallShiftMaterializer.getCandidateScheduleIdsForUsers).toBe(
3085
+ "function",
3086
+ );
3087
+ });
3088
+ });