@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,1037 @@
1
+ import OnCallDutyPolicyScheduleService, {
2
+ ResolvedPolicyVariant,
3
+ ResolvedShiftSegments,
4
+ } from "../../../Server/Services/OnCallDutyPolicyScheduleService";
5
+ import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
6
+ import logger from "../../../Server/Utils/Logger";
7
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
8
+ import BadDataException from "../../../Types/Exception/BadDataException";
9
+ import EventInterval from "../../../Types/Events/EventInterval";
10
+ import { MaterializedShiftPolicy } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
11
+ import UserOverrideUtil, {
12
+ UserOverrideRecord,
13
+ } from "../../../Types/OnCallDutyPolicy/UserOverrideUtil";
14
+ import {
15
+ at,
16
+ dailyRestriction,
17
+ rotation,
18
+ } from "../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
19
+ import {
20
+ FakeDb,
21
+ emptyDb,
22
+ installFakeDb,
23
+ makeAttachment,
24
+ makeLayer,
25
+ makeLayerUser,
26
+ makeOverride,
27
+ makeSchedule,
28
+ oid,
29
+ } from "../Utils/OnCall/OnCallResolverTestHarness";
30
+ import {
31
+ afterEach,
32
+ beforeEach,
33
+ describe,
34
+ expect,
35
+ jest,
36
+ test,
37
+ } from "@jest/globals";
38
+
39
+ /*
40
+ * getResolvedShiftSegments / getResolvedShiftSegmentsForSchedules: the
41
+ * source of truth behind the calendar feeds. Runs the REAL service code
42
+ * (layer props builder, window widening, engine, override selection, policy
43
+ * variants, lastModifiedAt) against an in-memory fake of the tables it reads.
44
+ */
45
+
46
+ const UTC: string = "UTC";
47
+ const PROJECT: string = "project-1";
48
+ const OTHER_PROJECT: string = "project-2";
49
+ const SCHEDULE: string = "schedule-1";
50
+ const OTHER_SCHEDULE: string = "schedule-2";
51
+
52
+ const WINDOW_START: Date = at("2026-03-02T00:00:00Z");
53
+ const WINDOW_END: Date = at("2026-03-05T00:00:00Z");
54
+
55
+ // A daily A/B rotation handing off at 09:00 UTC, running since New Year.
56
+ function dailyLayer(
57
+ db: FakeDb,
58
+ data?: {
59
+ scheduleId?: string;
60
+ projectId?: string;
61
+ layerId?: string;
62
+ users?: Array<string>;
63
+ order?: number;
64
+ restricted?: boolean;
65
+ updatedAt?: Date;
66
+ },
67
+ ): void {
68
+ const scheduleId: string = data?.scheduleId ?? SCHEDULE;
69
+ const projectId: string = data?.projectId ?? PROJECT;
70
+ const layerId: string = data?.layerId ?? "layer-1";
71
+ const users: Array<string> = data?.users ?? ["A", "B"];
72
+
73
+ db.layers.push(
74
+ makeLayer({
75
+ id: layerId,
76
+ scheduleId,
77
+ projectId,
78
+ name: "Primary",
79
+ order: data?.order ?? 1,
80
+ startsAt: at("2026-01-01T00:00:00Z"),
81
+ handOffTime: at("2026-01-01T09:00:00Z"),
82
+ rotation: rotation(EventInterval.Day, 1),
83
+ restrictionTimes: data?.restricted
84
+ ? dailyRestriction("09:00", "17:00", UTC)
85
+ : undefined,
86
+ updatedAt: data?.updatedAt,
87
+ }),
88
+ );
89
+
90
+ users.forEach((userId: string, index: number) => {
91
+ db.layerUsers.push(
92
+ makeLayerUser({
93
+ id: `${layerId}-user-${userId}`,
94
+ scheduleId,
95
+ layerId,
96
+ projectId,
97
+ userId,
98
+ order: index + 1,
99
+ }),
100
+ );
101
+ });
102
+ }
103
+
104
+ function baseDb(): FakeDb {
105
+ const db: FakeDb = emptyDb();
106
+ db.schedules.push(
107
+ makeSchedule({
108
+ id: SCHEDULE,
109
+ projectId: PROJECT,
110
+ name: "Payments",
111
+ timezone: UTC,
112
+ shiftConfigVersion: 7,
113
+ }),
114
+ );
115
+ dailyLayer(db);
116
+ return db;
117
+ }
118
+
119
+ function coveringSegment(
120
+ segments: Array<CalendarEvent>,
121
+ instant: Date,
122
+ ): CalendarEvent | undefined {
123
+ return segments.find((segment: CalendarEvent) => {
124
+ return (
125
+ segment.start.getTime() <= instant.getTime() &&
126
+ segment.end.getTime() > instant.getTime()
127
+ );
128
+ });
129
+ }
130
+
131
+ async function resolve(
132
+ db: FakeDb,
133
+ options?: { maxSimulationIterations?: number; scheduleId?: string },
134
+ ): Promise<ResolvedShiftSegments> {
135
+ installFakeDb(db);
136
+
137
+ const resolved: ResolvedShiftSegments | null =
138
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegments({
139
+ scheduleId: oid(options?.scheduleId ?? SCHEDULE),
140
+ windowStart: WINDOW_START,
141
+ windowEnd: WINDOW_END,
142
+ maxSimulationIterations: options?.maxSimulationIterations,
143
+ });
144
+
145
+ expect(resolved).not.toBeNull();
146
+ return resolved!;
147
+ }
148
+
149
+ describe("OnCallDutyPolicyScheduleService shift resolver", () => {
150
+ beforeEach(() => {
151
+ jest.spyOn(logger, "error").mockImplementation((): void => {
152
+ return undefined;
153
+ });
154
+ });
155
+
156
+ afterEach(() => {
157
+ jest.restoreAllMocks();
158
+ });
159
+
160
+ describe("window handling", () => {
161
+ test("returns only segments overlapping the window, UNCLIPPED at both ends", async () => {
162
+ const resolved: ResolvedShiftSegments = await resolve(baseDb());
163
+
164
+ expect(resolved.segments.length).toBeGreaterThan(0);
165
+
166
+ for (const segment of resolved.segments) {
167
+ expect(segment.start.getTime()).toBeLessThan(WINDOW_END.getTime());
168
+ expect(segment.end.getTime()).toBeGreaterThan(WINDOW_START.getTime());
169
+ }
170
+
171
+ // The shift covering the window start began at the previous 09:00 handoff.
172
+ const first: CalendarEvent = resolved.segments[0]!;
173
+ expect(first.start.getTime()).toBeLessThan(WINDOW_START.getTime());
174
+ expect(first.start.toISOString()).toMatch(/^2026-03-01T09:00:0[01]/);
175
+
176
+ // The shift covering the window end runs to the next 09:00 handoff.
177
+ const last: CalendarEvent =
178
+ resolved.segments[resolved.segments.length - 1]!;
179
+ expect(last.end.getTime()).toBeGreaterThan(WINDOW_END.getTime());
180
+ expect(last.end.toISOString()).toMatch(/^2026-03-05T09:00:00/);
181
+ });
182
+
183
+ test("segments alternate between the layer users at each 09:00 handoff", async () => {
184
+ const resolved: ResolvedShiftSegments = await resolve(baseDb());
185
+
186
+ const titles: Array<string> = resolved.segments.map(
187
+ (segment: CalendarEvent) => {
188
+ return segment.title;
189
+ },
190
+ );
191
+
192
+ expect(titles.length).toBe(4); // 03-01 09:00 .. 03-05 09:00 = four daily shifts
193
+ for (let i: number = 1; i < titles.length; i++) {
194
+ expect(titles[i]).not.toBe(titles[i - 1]);
195
+ expect(["A", "B"]).toContain(titles[i]);
196
+ }
197
+ });
198
+
199
+ test("rejects an inverted window", async () => {
200
+ installFakeDb(baseDb());
201
+
202
+ await expect(
203
+ OnCallDutyPolicyScheduleService.getResolvedShiftSegments({
204
+ scheduleId: oid(SCHEDULE),
205
+ windowStart: WINDOW_END,
206
+ windowEnd: WINDOW_START,
207
+ }),
208
+ ).rejects.toBeInstanceOf(BadDataException);
209
+ });
210
+
211
+ test("a schedule with no layers resolves to no segments and no variants", async () => {
212
+ const db: FakeDb = emptyDb();
213
+ db.schedules.push(
214
+ makeSchedule({ id: SCHEDULE, projectId: PROJECT, timezone: UTC }),
215
+ );
216
+
217
+ const resolved: ResolvedShiftSegments = await resolve(db);
218
+
219
+ expect(resolved.segments).toEqual([]);
220
+ expect(resolved.policyVariants).toEqual([]);
221
+ expect(resolved.layerProps).toEqual([]);
222
+ expect(resolved.truncated).toBe(false);
223
+ });
224
+
225
+ test("an unknown schedule resolves to null (and is skipped by the batch)", async () => {
226
+ installFakeDb(baseDb());
227
+
228
+ const single: ResolvedShiftSegments | null =
229
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegments({
230
+ scheduleId: oid("missing"),
231
+ windowStart: WINDOW_START,
232
+ windowEnd: WINDOW_END,
233
+ });
234
+ expect(single).toBeNull();
235
+
236
+ const batch: Array<ResolvedShiftSegments> =
237
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegmentsForSchedules(
238
+ {
239
+ scheduleIds: [oid("missing"), oid(SCHEDULE)],
240
+ windowStart: WINDOW_START,
241
+ windowEnd: WINDOW_END,
242
+ },
243
+ );
244
+ expect(batch).toHaveLength(1);
245
+ expect(batch[0]!.schedule.id).toBe(SCHEDULE);
246
+ });
247
+
248
+ test("an empty id list resolves to an empty batch without touching the database", async () => {
249
+ installFakeDb(baseDb());
250
+
251
+ const batch: Array<ResolvedShiftSegments> =
252
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegmentsForSchedules(
253
+ {
254
+ scheduleIds: [],
255
+ windowStart: WINDOW_START,
256
+ windowEnd: WINDOW_END,
257
+ },
258
+ );
259
+
260
+ expect(batch).toEqual([]);
261
+ expect(OnCallDutyPolicyScheduleService.findBy).not.toHaveBeenCalled();
262
+ });
263
+ });
264
+
265
+ describe("schedule info and layer props", () => {
266
+ test("carries the schedule columns and stamps layer id/name on the layer props and events", async () => {
267
+ const resolved: ResolvedShiftSegments = await resolve(baseDb());
268
+
269
+ expect(resolved.schedule).toEqual({
270
+ id: SCHEDULE,
271
+ name: "Payments",
272
+ timezone: UTC,
273
+ projectId: PROJECT,
274
+ shiftConfigVersion: 7,
275
+ });
276
+
277
+ expect(resolved.layerProps).toHaveLength(1);
278
+ expect(resolved.layerProps[0]!.layerId).toBe("layer-1");
279
+ expect(resolved.layerProps[0]!.layerName).toBe("Primary");
280
+ expect(resolved.layerProps[0]!.timezone).toBe(UTC);
281
+ expect(resolved.scheduleUserIds).toEqual(["A", "B"]);
282
+
283
+ for (const segment of resolved.segments) {
284
+ expect((segment as unknown as Record<string, unknown>)["layerId"]).toBe(
285
+ "layer-1",
286
+ );
287
+ expect(
288
+ (segment as unknown as Record<string, unknown>)["layerName"],
289
+ ).toBe("Primary");
290
+ }
291
+ });
292
+
293
+ test("omits the timezone key for a legacy schedule without one", async () => {
294
+ const db: FakeDb = baseDb();
295
+ db.schedules[0]!["timezone"] = undefined;
296
+
297
+ const resolved: ResolvedShiftSegments = await resolve(db);
298
+
299
+ expect("timezone" in resolved.schedule).toBe(false);
300
+ expect(resolved.layerProps[0]!.timezone).toBeUndefined();
301
+ });
302
+
303
+ test("a non-numeric shiftConfigVersion reads as 0", async () => {
304
+ const db: FakeDb = baseDb();
305
+ db.schedules[0]!["shiftConfigVersion"] = undefined;
306
+
307
+ const resolved: ResolvedShiftSegments = await resolve(db);
308
+ expect(resolved.schedule.shiftConfigVersion).toBe(0);
309
+ });
310
+ });
311
+
312
+ describe("attached policies", () => {
313
+ test("dedupes (policy, rule) rows and orders them", async () => {
314
+ const db: FakeDb = baseDb();
315
+ db.attachments.push(
316
+ makeAttachment({
317
+ id: "att-1",
318
+ scheduleId: SCHEDULE,
319
+ projectId: PROJECT,
320
+ policyId: "P1",
321
+ policyName: "Payments Policy",
322
+ ruleId: "R2",
323
+ ruleName: "Secondary",
324
+ ruleOrder: 2,
325
+ }),
326
+ makeAttachment({
327
+ id: "att-2",
328
+ scheduleId: SCHEDULE,
329
+ projectId: PROJECT,
330
+ policyId: "P1",
331
+ policyName: "Payments Policy",
332
+ ruleId: "R1",
333
+ ruleName: "Primary",
334
+ ruleOrder: 1,
335
+ }),
336
+ // Duplicate row for the same (policy, rule): the join has no unique index.
337
+ makeAttachment({
338
+ id: "att-3",
339
+ scheduleId: SCHEDULE,
340
+ projectId: PROJECT,
341
+ policyId: "P1",
342
+ policyName: "Payments Policy",
343
+ ruleId: "R1",
344
+ ruleName: "Primary",
345
+ ruleOrder: 1,
346
+ }),
347
+ );
348
+
349
+ const resolved: ResolvedShiftSegments = await resolve(db);
350
+
351
+ expect(resolved.attachedPolicies).toEqual([
352
+ {
353
+ policyId: "P1",
354
+ policyName: "Payments Policy",
355
+ ruleId: "R1",
356
+ ruleName: "Primary",
357
+ ruleOrder: 1,
358
+ },
359
+ {
360
+ policyId: "P1",
361
+ policyName: "Payments Policy",
362
+ ruleId: "R2",
363
+ ruleName: "Secondary",
364
+ ruleOrder: 2,
365
+ },
366
+ ]);
367
+ });
368
+
369
+ test("attachments of other schedules are not mixed in", async () => {
370
+ const db: FakeDb = baseDb();
371
+ db.schedules.push(
372
+ makeSchedule({ id: OTHER_SCHEDULE, projectId: PROJECT, timezone: UTC }),
373
+ );
374
+ db.attachments.push(
375
+ makeAttachment({
376
+ id: "att-other",
377
+ scheduleId: OTHER_SCHEDULE,
378
+ projectId: PROJECT,
379
+ policyId: "P9",
380
+ ruleId: "R9",
381
+ }),
382
+ );
383
+
384
+ const resolved: ResolvedShiftSegments = await resolve(db);
385
+ expect(resolved.attachedPolicies).toEqual([]);
386
+ });
387
+ });
388
+
389
+ describe("overrides in the roster's policy context", () => {
390
+ const OVERRIDE_START: Date = at("2026-03-03T11:00:00Z");
391
+ const OVERRIDE_END: Date = at("2026-03-03T13:00:00Z");
392
+
393
+ function whoIsOnAt(
394
+ resolved: ResolvedShiftSegments,
395
+ instant: Date,
396
+ ): string | undefined {
397
+ return coveringSegment(resolved.segments, instant)?.title;
398
+ }
399
+
400
+ test("a GLOBAL override substitutes the user inside its window and stamps override meta", async () => {
401
+ const db: FakeDb = baseDb();
402
+ const onCallAtNoon: string = whoIsOnAt(
403
+ await resolve(db),
404
+ at("2026-03-03T12:00:00Z"),
405
+ )!;
406
+
407
+ db.overrides.push(
408
+ makeOverride({
409
+ id: "ov-1",
410
+ projectId: PROJECT,
411
+ overrideUserId: onCallAtNoon,
412
+ routeAlertsToUserId: "C",
413
+ startsAt: OVERRIDE_START,
414
+ endsAt: OVERRIDE_END,
415
+ }),
416
+ );
417
+
418
+ const resolved: ResolvedShiftSegments = await resolve(db);
419
+
420
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).toBe("C");
421
+ expect(whoIsOnAt(resolved, at("2026-03-03T10:00:00Z"))).toBe(
422
+ onCallAtNoon,
423
+ );
424
+ expect(whoIsOnAt(resolved, at("2026-03-03T14:00:00Z"))).toBe(
425
+ onCallAtNoon,
426
+ );
427
+
428
+ const substituted: CalendarEvent = coveringSegment(
429
+ resolved.segments,
430
+ at("2026-03-03T12:00:00Z"),
431
+ )!;
432
+ expect(substituted.start).toEqual(OVERRIDE_START);
433
+ expect(substituted.end).toEqual(OVERRIDE_END);
434
+ expect(UserOverrideUtil.getOverrideMeta(substituted)).toMatchObject({
435
+ originalUserId: onCallAtNoon,
436
+ overrideUserId: "C",
437
+ });
438
+
439
+ // The override took part in the resolution.
440
+ expect(resolved.overrides).toHaveLength(1);
441
+ expect(resolved.overrides[0]).toMatchObject({
442
+ overrideUserId: onCallAtNoon,
443
+ routeAlertsToUserId: "C",
444
+ onCallDutyPolicyId: null,
445
+ });
446
+ });
447
+
448
+ test("an override for a NON-member is ignored", async () => {
449
+ const db: FakeDb = baseDb();
450
+ db.overrides.push(
451
+ makeOverride({
452
+ id: "ov-stranger",
453
+ projectId: PROJECT,
454
+ overrideUserId: "Z",
455
+ routeAlertsToUserId: "C",
456
+ startsAt: OVERRIDE_START,
457
+ endsAt: OVERRIDE_END,
458
+ }),
459
+ );
460
+
461
+ const resolved: ResolvedShiftSegments = await resolve(db);
462
+
463
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).not.toBe("C");
464
+ expect(resolved.overrides).toEqual([]);
465
+ });
466
+
467
+ test("an override in ANOTHER project is ignored even for a member", async () => {
468
+ const db: FakeDb = baseDb();
469
+ db.overrides.push(
470
+ makeOverride({
471
+ id: "ov-other-project",
472
+ projectId: OTHER_PROJECT,
473
+ overrideUserId: "A",
474
+ routeAlertsToUserId: "C",
475
+ startsAt: OVERRIDE_START,
476
+ endsAt: OVERRIDE_END,
477
+ }),
478
+ makeOverride({
479
+ id: "ov-other-project-2",
480
+ projectId: OTHER_PROJECT,
481
+ overrideUserId: "B",
482
+ routeAlertsToUserId: "C",
483
+ startsAt: OVERRIDE_START,
484
+ endsAt: OVERRIDE_END,
485
+ }),
486
+ );
487
+
488
+ const resolved: ResolvedShiftSegments = await resolve(db);
489
+
490
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).not.toBe("C");
491
+ expect(resolved.overrides).toEqual([]);
492
+ });
493
+
494
+ test("an override outside the (widened) window does not participate", async () => {
495
+ const db: FakeDb = baseDb();
496
+ db.overrides.push(
497
+ makeOverride({
498
+ id: "ov-far",
499
+ projectId: PROJECT,
500
+ overrideUserId: "A",
501
+ routeAlertsToUserId: "C",
502
+ startsAt: at("2026-06-01T00:00:00Z"),
503
+ endsAt: at("2026-06-02T00:00:00Z"),
504
+ }),
505
+ );
506
+
507
+ const resolved: ResolvedShiftSegments = await resolve(db);
508
+ expect(resolved.overrides).toEqual([]);
509
+ });
510
+
511
+ test("with exactly ONE attached policy, that policy's scoped overrides apply (the roster rule)", async () => {
512
+ const db: FakeDb = baseDb();
513
+ db.attachments.push(
514
+ makeAttachment({
515
+ id: "att-1",
516
+ scheduleId: SCHEDULE,
517
+ projectId: PROJECT,
518
+ policyId: "P1",
519
+ ruleId: "R1",
520
+ }),
521
+ );
522
+ const onCallAtNoon: string = whoIsOnAt(
523
+ await resolve(db),
524
+ at("2026-03-03T12:00:00Z"),
525
+ )!;
526
+
527
+ db.overrides.push(
528
+ makeOverride({
529
+ id: "ov-scoped",
530
+ projectId: PROJECT,
531
+ overrideUserId: onCallAtNoon,
532
+ routeAlertsToUserId: "D",
533
+ startsAt: OVERRIDE_START,
534
+ endsAt: OVERRIDE_END,
535
+ policyId: "P1",
536
+ }),
537
+ );
538
+
539
+ const resolved: ResolvedShiftSegments = await resolve(db);
540
+
541
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).toBe("D");
542
+ expect(resolved.policyVariants).toEqual([]);
543
+ expect(resolved.overrides[0]!.onCallDutyPolicyId).toBe("P1");
544
+ });
545
+
546
+ test("a scoped override for a policy the schedule is NOT attached to is ignored", async () => {
547
+ const db: FakeDb = baseDb();
548
+ db.attachments.push(
549
+ makeAttachment({
550
+ id: "att-1",
551
+ scheduleId: SCHEDULE,
552
+ projectId: PROJECT,
553
+ policyId: "P1",
554
+ ruleId: "R1",
555
+ }),
556
+ );
557
+ db.overrides.push(
558
+ makeOverride({
559
+ id: "ov-scoped-other",
560
+ projectId: PROJECT,
561
+ overrideUserId: "A",
562
+ routeAlertsToUserId: "D",
563
+ startsAt: OVERRIDE_START,
564
+ endsAt: OVERRIDE_END,
565
+ policyId: "P2",
566
+ }),
567
+ makeOverride({
568
+ id: "ov-scoped-other-b",
569
+ projectId: PROJECT,
570
+ overrideUserId: "B",
571
+ routeAlertsToUserId: "D",
572
+ startsAt: OVERRIDE_START,
573
+ endsAt: OVERRIDE_END,
574
+ policyId: "P2",
575
+ }),
576
+ );
577
+
578
+ const resolved: ResolvedShiftSegments = await resolve(db);
579
+
580
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).not.toBe("D");
581
+ expect(resolved.policyVariants).toEqual([]);
582
+ expect(resolved.overrides).toEqual([]);
583
+ });
584
+
585
+ test("with ZERO attached policies, scoped overrides never apply", async () => {
586
+ const db: FakeDb = baseDb();
587
+ db.overrides.push(
588
+ makeOverride({
589
+ id: "ov-scoped",
590
+ projectId: PROJECT,
591
+ overrideUserId: "A",
592
+ routeAlertsToUserId: "D",
593
+ startsAt: OVERRIDE_START,
594
+ endsAt: OVERRIDE_END,
595
+ policyId: "P1",
596
+ }),
597
+ makeOverride({
598
+ id: "ov-scoped-b",
599
+ projectId: PROJECT,
600
+ overrideUserId: "B",
601
+ routeAlertsToUserId: "D",
602
+ startsAt: OVERRIDE_START,
603
+ endsAt: OVERRIDE_END,
604
+ policyId: "P1",
605
+ }),
606
+ );
607
+
608
+ const resolved: ResolvedShiftSegments = await resolve(db);
609
+ expect(whoIsOnAt(resolved, at("2026-03-03T12:00:00Z"))).not.toBe("D");
610
+ expect(resolved.policyVariants).toEqual([]);
611
+ });
612
+ });
613
+
614
+ describe("policy variants (two or more attached policies)", () => {
615
+ const OVERRIDE_START: Date = at("2026-03-03T09:00:00Z");
616
+ const OVERRIDE_END: Date = at("2026-03-04T09:00:00Z");
617
+
618
+ function twoPolicyDb(): FakeDb {
619
+ const db: FakeDb = baseDb();
620
+ db.attachments.push(
621
+ makeAttachment({
622
+ id: "att-1",
623
+ scheduleId: SCHEDULE,
624
+ projectId: PROJECT,
625
+ policyId: "P1",
626
+ policyName: "Payments",
627
+ ruleId: "R1",
628
+ }),
629
+ makeAttachment({
630
+ id: "att-2",
631
+ scheduleId: SCHEDULE,
632
+ projectId: PROJECT,
633
+ policyId: "P2",
634
+ policyName: "Checkout",
635
+ ruleId: "R2",
636
+ }),
637
+ );
638
+ return db;
639
+ }
640
+
641
+ test("no scoped override overlapping the window => no variants, base is global-only", async () => {
642
+ const db: FakeDb = twoPolicyDb();
643
+
644
+ const resolved: ResolvedShiftSegments = await resolve(db);
645
+
646
+ expect(resolved.policyVariants).toEqual([]);
647
+ expect(
648
+ resolved.attachedPolicies.map((p: MaterializedShiftPolicy) => {
649
+ return p.policyId;
650
+ }),
651
+ ).toEqual(["P2", "P1"]);
652
+ });
653
+
654
+ test("a scoped override for a member yields a variant for THAT policy only, base stays global", async () => {
655
+ const db: FakeDb = twoPolicyDb();
656
+ const baseline: ResolvedShiftSegments = await resolve(db);
657
+ const onCall: string = coveringSegment(
658
+ baseline.segments,
659
+ at("2026-03-03T12:00:00Z"),
660
+ )!.title;
661
+
662
+ db.overrides.push(
663
+ makeOverride({
664
+ id: "ov-p1",
665
+ projectId: PROJECT,
666
+ overrideUserId: onCall,
667
+ routeAlertsToUserId: "E",
668
+ startsAt: OVERRIDE_START,
669
+ endsAt: OVERRIDE_END,
670
+ policyId: "P1",
671
+ }),
672
+ );
673
+
674
+ const resolved: ResolvedShiftSegments = await resolve(db);
675
+
676
+ // Base (global-only) still names the rostered person.
677
+ expect(
678
+ coveringSegment(resolved.segments, at("2026-03-03T12:00:00Z"))!.title,
679
+ ).toBe(onCall);
680
+
681
+ expect(resolved.policyVariants).toHaveLength(1);
682
+ const variant: ResolvedPolicyVariant = resolved.policyVariants[0]!;
683
+ expect(variant.policyId).toBe("P1");
684
+ expect(variant.policyName).toBe("Payments");
685
+ expect(
686
+ coveringSegment(variant.segments, at("2026-03-03T12:00:00Z"))!.title,
687
+ ).toBe("E");
688
+
689
+ // The variant's segments are window-filtered too.
690
+ for (const segment of variant.segments) {
691
+ expect(segment.start.getTime()).toBeLessThan(WINDOW_END.getTime());
692
+ expect(segment.end.getTime()).toBeGreaterThan(WINDOW_START.getTime());
693
+ }
694
+
695
+ // Both the scoped override is recorded as participating, with its scope.
696
+ expect(
697
+ resolved.overrides.filter((o: UserOverrideRecord) => {
698
+ return o.onCallDutyPolicyId === "P1";
699
+ }),
700
+ ).toHaveLength(1);
701
+ });
702
+
703
+ test("global overrides apply inside every variant as well", async () => {
704
+ const db: FakeDb = twoPolicyDb();
705
+ const baseline: ResolvedShiftSegments = await resolve(db);
706
+ const onCallDay3: string = coveringSegment(
707
+ baseline.segments,
708
+ at("2026-03-03T12:00:00Z"),
709
+ )!.title;
710
+ const onCallDay4: string = coveringSegment(
711
+ baseline.segments,
712
+ at("2026-03-04T12:00:00Z"),
713
+ )!.title;
714
+
715
+ db.overrides.push(
716
+ makeOverride({
717
+ id: "ov-global",
718
+ projectId: PROJECT,
719
+ overrideUserId: onCallDay4,
720
+ routeAlertsToUserId: "G",
721
+ startsAt: at("2026-03-04T10:00:00Z"),
722
+ endsAt: at("2026-03-04T12:30:00Z"),
723
+ }),
724
+ makeOverride({
725
+ id: "ov-p2",
726
+ projectId: PROJECT,
727
+ overrideUserId: onCallDay3,
728
+ routeAlertsToUserId: "E",
729
+ startsAt: OVERRIDE_START,
730
+ endsAt: OVERRIDE_END,
731
+ policyId: "P2",
732
+ }),
733
+ );
734
+
735
+ const resolved: ResolvedShiftSegments = await resolve(db);
736
+
737
+ const variant: ResolvedPolicyVariant = resolved.policyVariants.find(
738
+ (v: ResolvedPolicyVariant) => {
739
+ return v.policyId === "P2";
740
+ },
741
+ )!;
742
+ expect(variant).toBeDefined();
743
+ expect(
744
+ coveringSegment(variant.segments, at("2026-03-03T12:00:00Z"))!.title,
745
+ ).toBe("E");
746
+ expect(
747
+ coveringSegment(variant.segments, at("2026-03-04T11:00:00Z"))!.title,
748
+ ).toBe("G");
749
+ expect(
750
+ coveringSegment(resolved.segments, at("2026-03-04T11:00:00Z"))!.title,
751
+ ).toBe("G");
752
+ });
753
+
754
+ test("scoped overrides for a non-member do not create a variant", async () => {
755
+ const db: FakeDb = twoPolicyDb();
756
+ db.overrides.push(
757
+ makeOverride({
758
+ id: "ov-p1-stranger",
759
+ projectId: PROJECT,
760
+ overrideUserId: "Z",
761
+ routeAlertsToUserId: "E",
762
+ startsAt: OVERRIDE_START,
763
+ endsAt: OVERRIDE_END,
764
+ policyId: "P1",
765
+ }),
766
+ );
767
+
768
+ const resolved: ResolvedShiftSegments = await resolve(db);
769
+ expect(resolved.policyVariants).toEqual([]);
770
+ });
771
+ });
772
+
773
+ describe("lastModifiedAt", () => {
774
+ test("is the max updatedAt over layers, layer users, attachments and participating overrides — never the schedule row", async () => {
775
+ const db: FakeDb = emptyDb();
776
+ db.schedules.push(
777
+ makeSchedule({
778
+ id: SCHEDULE,
779
+ projectId: PROJECT,
780
+ timezone: UTC,
781
+ updatedAt: at("2026-12-31T00:00:00Z"), // roster refresh noise
782
+ }),
783
+ );
784
+ dailyLayer(db, { updatedAt: at("2026-08-01T00:00:00Z") });
785
+ db.layerUsers[0]!["updatedAt"] = at("2026-08-05T00:00:00Z");
786
+ db.attachments.push(
787
+ makeAttachment({
788
+ id: "att-1",
789
+ scheduleId: SCHEDULE,
790
+ projectId: PROJECT,
791
+ policyId: "P1",
792
+ ruleId: "R1",
793
+ updatedAt: at("2026-08-03T00:00:00Z"),
794
+ }),
795
+ );
796
+ db.overrides.push(
797
+ makeOverride({
798
+ id: "ov-1",
799
+ projectId: PROJECT,
800
+ overrideUserId: "A",
801
+ routeAlertsToUserId: "C",
802
+ startsAt: at("2026-03-03T11:00:00Z"),
803
+ endsAt: at("2026-03-03T13:00:00Z"),
804
+ updatedAt: at("2026-08-10T00:00:00Z"),
805
+ }),
806
+ );
807
+
808
+ const resolved: ResolvedShiftSegments = await resolve(db);
809
+ expect(resolved.lastModifiedAt.toISOString()).toBe(
810
+ "2026-08-10T00:00:00.000Z",
811
+ );
812
+ });
813
+
814
+ test("an override that does not participate does not count", async () => {
815
+ const db: FakeDb = baseDb();
816
+ db.overrides.push(
817
+ makeOverride({
818
+ id: "ov-far",
819
+ projectId: PROJECT,
820
+ overrideUserId: "A",
821
+ routeAlertsToUserId: "C",
822
+ startsAt: at("2027-01-01T00:00:00Z"),
823
+ endsAt: at("2027-01-02T00:00:00Z"),
824
+ updatedAt: at("2027-06-01T00:00:00Z"),
825
+ }),
826
+ );
827
+
828
+ const resolved: ResolvedShiftSegments = await resolve(db);
829
+ expect(resolved.lastModifiedAt.toISOString()).toBe(
830
+ "2026-08-01T10:00:00.000Z",
831
+ );
832
+ });
833
+
834
+ test("falls back to the schedule's createdAt when nothing is configured", async () => {
835
+ const db: FakeDb = emptyDb();
836
+ db.schedules.push(
837
+ makeSchedule({
838
+ id: SCHEDULE,
839
+ projectId: PROJECT,
840
+ createdAt: at("2026-02-02T02:02:02Z"),
841
+ }),
842
+ );
843
+
844
+ const resolved: ResolvedShiftSegments = await resolve(db);
845
+ expect(resolved.lastModifiedAt.toISOString()).toBe(
846
+ "2026-02-02T02:02:02.000Z",
847
+ );
848
+ });
849
+
850
+ test("is identical across two resolutions of an unchanged configuration", async () => {
851
+ const first: ResolvedShiftSegments = await resolve(baseDb());
852
+ jest.restoreAllMocks();
853
+ jest.spyOn(logger, "error").mockImplementation((): void => {
854
+ return undefined;
855
+ });
856
+ const second: ResolvedShiftSegments = await resolve(baseDb());
857
+
858
+ expect(first.lastModifiedAt).toEqual(second.lastModifiedAt);
859
+ expect(
860
+ first.segments.map((s: CalendarEvent) => {
861
+ return [s.title, s.start, s.end];
862
+ }),
863
+ ).toEqual(
864
+ second.segments.map((s: CalendarEvent) => {
865
+ return [s.title, s.start, s.end];
866
+ }),
867
+ );
868
+ });
869
+ });
870
+
871
+ describe("truncation", () => {
872
+ test("propagates the engine's truncated flag when the iteration cap is hit", async () => {
873
+ const db: FakeDb = emptyDb();
874
+ db.schedules.push(
875
+ makeSchedule({ id: SCHEDULE, projectId: PROJECT, timezone: UTC }),
876
+ );
877
+ /*
878
+ * A restricted hourly rotation running for years: the pre-window
879
+ * simulation needs tens of thousands of periods.
880
+ */
881
+ db.layers.push(
882
+ makeLayer({
883
+ id: "layer-old",
884
+ scheduleId: SCHEDULE,
885
+ projectId: PROJECT,
886
+ startsAt: at("2020-01-01T00:00:00Z"),
887
+ handOffTime: at("2020-01-01T09:00:00Z"),
888
+ rotation: rotation(EventInterval.Hour, 1),
889
+ restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
890
+ }),
891
+ );
892
+ db.layerUsers.push(
893
+ makeLayerUser({
894
+ id: "lu-a",
895
+ scheduleId: SCHEDULE,
896
+ layerId: "layer-old",
897
+ projectId: PROJECT,
898
+ userId: "A",
899
+ }),
900
+ );
901
+
902
+ const resolved: ResolvedShiftSegments = await resolve(db, {
903
+ maxSimulationIterations: 10,
904
+ });
905
+
906
+ expect(resolved.truncated).toBe(true);
907
+ });
908
+
909
+ test("is false for an ordinary schedule", async () => {
910
+ const resolved: ResolvedShiftSegments = await resolve(baseDb(), {
911
+ maxSimulationIterations: 100000,
912
+ });
913
+ expect(resolved.truncated).toBe(false);
914
+ expect(resolved.segments.length).toBeGreaterThan(0);
915
+ });
916
+ });
917
+
918
+ describe("batched resolution", () => {
919
+ test("resolves several schedules across projects with one override query, scoping overrides per project", async () => {
920
+ const db: FakeDb = baseDb();
921
+ db.schedules.push(
922
+ makeSchedule({
923
+ id: OTHER_SCHEDULE,
924
+ projectId: OTHER_PROJECT,
925
+ name: "Checkout",
926
+ timezone: UTC,
927
+ }),
928
+ );
929
+ dailyLayer(db, {
930
+ scheduleId: OTHER_SCHEDULE,
931
+ projectId: OTHER_PROJECT,
932
+ layerId: "layer-2",
933
+ users: ["A", "X"],
934
+ });
935
+ // A is a member of both schedules; this override lives in project 2 only.
936
+ db.overrides.push(
937
+ makeOverride({
938
+ id: "ov-p2",
939
+ projectId: OTHER_PROJECT,
940
+ overrideUserId: "A",
941
+ routeAlertsToUserId: "C",
942
+ startsAt: at("2026-03-01T00:00:00Z"),
943
+ endsAt: at("2026-03-06T00:00:00Z"),
944
+ }),
945
+ makeOverride({
946
+ id: "ov-p2-x",
947
+ projectId: OTHER_PROJECT,
948
+ overrideUserId: "X",
949
+ routeAlertsToUserId: "C",
950
+ startsAt: at("2026-03-01T00:00:00Z"),
951
+ endsAt: at("2026-03-06T00:00:00Z"),
952
+ }),
953
+ );
954
+
955
+ installFakeDb(db);
956
+
957
+ const batch: Array<ResolvedShiftSegments> =
958
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegmentsForSchedules(
959
+ {
960
+ scheduleIds: [oid(SCHEDULE), oid(OTHER_SCHEDULE), oid(SCHEDULE)],
961
+ windowStart: WINDOW_START,
962
+ windowEnd: WINDOW_END,
963
+ },
964
+ );
965
+
966
+ expect(
967
+ batch.map((r: ResolvedShiftSegments) => {
968
+ return r.schedule.id;
969
+ }),
970
+ ).toEqual([SCHEDULE, OTHER_SCHEDULE]);
971
+ expect(OnCallDutyPolicyUserOverrideService.findBy).toHaveBeenCalledTimes(
972
+ 1,
973
+ );
974
+
975
+ const [first, second] = batch as [
976
+ ResolvedShiftSegments,
977
+ ResolvedShiftSegments,
978
+ ];
979
+
980
+ // Project 1: nobody substituted.
981
+ expect(first.overrides).toEqual([]);
982
+ for (const segment of first.segments) {
983
+ expect(["A", "B"]).toContain(segment.title);
984
+ }
985
+
986
+ // Project 2: everyone substituted by C for the whole window.
987
+ expect(second.overrides).toHaveLength(2);
988
+ for (const segment of second.segments) {
989
+ expect(segment.title).toBe("C");
990
+ }
991
+ });
992
+
993
+ test("each schedule reports its own truncated flag and layer props", async () => {
994
+ const db: FakeDb = baseDb();
995
+ db.schedules.push(
996
+ makeSchedule({ id: OTHER_SCHEDULE, projectId: PROJECT, timezone: UTC }),
997
+ );
998
+ db.layers.push(
999
+ makeLayer({
1000
+ id: "layer-old",
1001
+ scheduleId: OTHER_SCHEDULE,
1002
+ projectId: PROJECT,
1003
+ startsAt: at("2020-01-01T00:00:00Z"),
1004
+ handOffTime: at("2020-01-01T09:00:00Z"),
1005
+ rotation: rotation(EventInterval.Hour, 1),
1006
+ restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
1007
+ }),
1008
+ );
1009
+ db.layerUsers.push(
1010
+ makeLayerUser({
1011
+ id: "lu-old",
1012
+ scheduleId: OTHER_SCHEDULE,
1013
+ layerId: "layer-old",
1014
+ projectId: PROJECT,
1015
+ userId: "Q",
1016
+ }),
1017
+ );
1018
+
1019
+ installFakeDb(db);
1020
+
1021
+ const batch: Array<ResolvedShiftSegments> =
1022
+ await OnCallDutyPolicyScheduleService.getResolvedShiftSegmentsForSchedules(
1023
+ {
1024
+ scheduleIds: [oid(SCHEDULE), oid(OTHER_SCHEDULE)],
1025
+ windowStart: WINDOW_START,
1026
+ windowEnd: WINDOW_END,
1027
+ maxSimulationIterations: 10,
1028
+ },
1029
+ );
1030
+
1031
+ expect(batch[0]!.truncated).toBe(false);
1032
+ expect(batch[0]!.segments.length).toBeGreaterThan(0);
1033
+ expect(batch[1]!.truncated).toBe(true);
1034
+ expect(batch[1]!.layerProps[0]!.layerId).toBe("layer-old");
1035
+ });
1036
+ });
1037
+ });