@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,458 @@
1
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
2
+ import EventInterval from "../../../Types/Events/EventInterval";
3
+ import LayerUtil, {
4
+ LayerEventsResult,
5
+ LayerProps,
6
+ } from "../../../Types/OnCallDutyPolicy/Layer";
7
+ import ScheduleShiftUtil from "../../../Types/OnCallDutyPolicy/ScheduleShiftUtil";
8
+ import {
9
+ at,
10
+ dailyRestriction,
11
+ noRestriction,
12
+ rotation,
13
+ user,
14
+ } from "./CalendarFeedTestFixtures";
15
+
16
+ /*
17
+ * The two LayerUtil additions made for the on-call calendar feed:
18
+ *
19
+ * 1. LayerProps.layerId / layerName are stamped onto every merged event so a
20
+ * consumer can tell which layer a segment came from — and are ABSENT (not
21
+ * undefined) when the caller never set them, so existing callers see the
22
+ * exact event shape they always did.
23
+ * 2. LayerExpansionOptions.maxSimulationIterations bounds the per-layer work
24
+ * and reports `truncated` through getEventsWithMeta /
25
+ * getMultiLayerEventsWithMeta, while getEvents / getMultiLayerEvents stay
26
+ * byte-identical to before.
27
+ */
28
+
29
+ const UTC: string = "UTC";
30
+
31
+ const CAL_START: Date = at("2026-03-02T00:00:00Z");
32
+ const CAL_END: Date = at("2026-03-04T00:00:00Z");
33
+
34
+ /*
35
+ * The LayerUtilAuditFixes "Fix 3" fixture: primary A/B hourly rotation
36
+ * restricted to 10:00-12:00 (A[10:00-11:00], B[11:00:01-12:00] each day)
37
+ * over a 24/7 fallback layer with C.
38
+ */
39
+ function primaryLayer(withMeta: boolean): LayerProps {
40
+ const layer: LayerProps = {
41
+ users: [user("A"), user("B")],
42
+ startDateTimeOfLayer: CAL_START,
43
+ restrictionTimes: dailyRestriction("10:00", "12:00", UTC),
44
+ handOffTime: at("2026-03-02T11:00:00Z"),
45
+ rotation: rotation(EventInterval.Hour, 1),
46
+ timezone: UTC,
47
+ };
48
+
49
+ if (withMeta) {
50
+ layer.layerId = "layer-primary";
51
+ layer.layerName = "Primary";
52
+ }
53
+
54
+ return layer;
55
+ }
56
+
57
+ function fallbackLayer(withMeta: boolean): LayerProps {
58
+ const layer: LayerProps = {
59
+ users: [user("C")],
60
+ startDateTimeOfLayer: CAL_START,
61
+ restrictionTimes: noRestriction(),
62
+ handOffTime: CAL_START,
63
+ rotation: rotation(EventInterval.Week, 1),
64
+ timezone: UTC,
65
+ };
66
+
67
+ if (withMeta) {
68
+ layer.layerId = "layer-fallback";
69
+ layer.layerName = "Fallback";
70
+ }
71
+
72
+ return layer;
73
+ }
74
+
75
+ function coveringUser(
76
+ events: Array<CalendarEvent>,
77
+ instant: Date,
78
+ ): string | null {
79
+ for (const event of events) {
80
+ if (
81
+ event.start.getTime() <= instant.getTime() &&
82
+ event.end.getTime() >= instant.getTime()
83
+ ) {
84
+ return event.title;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ describe("LayerUtil: layerId / layerName stamping", () => {
91
+ test("every merged event carries the id and name of the layer it came from", () => {
92
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
93
+ layers: [primaryLayer(true), fallbackLayer(true)],
94
+ calendarStartDate: CAL_START,
95
+ calendarEndDate: CAL_END,
96
+ });
97
+
98
+ expect(events.length).toBeGreaterThan(0);
99
+
100
+ for (const event of events) {
101
+ const layerId: string | undefined =
102
+ ScheduleShiftUtil.getEventLayerId(event);
103
+ const layerName: string | undefined =
104
+ ScheduleShiftUtil.getEventLayerName(event);
105
+
106
+ if (event.title === "C") {
107
+ expect(layerId).toBe("layer-fallback");
108
+ expect(layerName).toBe("Fallback");
109
+ } else {
110
+ expect(["A", "B"]).toContain(event.title);
111
+ expect(layerId).toBe("layer-primary");
112
+ expect(layerName).toBe("Primary");
113
+ }
114
+ }
115
+ });
116
+
117
+ test("the fallback layer's trailing segment (split by the merge) keeps its layer", () => {
118
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
119
+ layers: [primaryLayer(true), fallbackLayer(true)],
120
+ calendarStartDate: CAL_START,
121
+ calendarEndDate: CAL_END,
122
+ });
123
+
124
+ // The afternoon of day 1 is C's coverage AFTER the primary window.
125
+ const afternoon: CalendarEvent | undefined = events.find(
126
+ (event: CalendarEvent) => {
127
+ return (
128
+ event.title === "C" &&
129
+ event.start.getTime() >= at("2026-03-02T12:00:00Z").getTime() &&
130
+ event.start.getTime() < at("2026-03-02T13:00:00Z").getTime()
131
+ );
132
+ },
133
+ );
134
+
135
+ expect(afternoon).toBeDefined();
136
+ expect(ScheduleShiftUtil.getEventLayerId(afternoon!)).toBe(
137
+ "layer-fallback",
138
+ );
139
+
140
+ // Coverage itself is unchanged by stamping.
141
+ expect(coveringUser(events, at("2026-03-02T10:30:00Z"))).toBe("A");
142
+ expect(coveringUser(events, at("2026-03-02T11:30:00Z"))).toBe("B");
143
+ expect(coveringUser(events, at("2026-03-02T15:00:00Z"))).toBe("C");
144
+ expect(coveringUser(events, at("2026-03-03T03:00:00Z"))).toBe("C");
145
+ });
146
+
147
+ test("no layer keys are added when LayerProps carries none", () => {
148
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
149
+ layers: [primaryLayer(false), fallbackLayer(false)],
150
+ calendarStartDate: CAL_START,
151
+ calendarEndDate: CAL_END,
152
+ });
153
+
154
+ expect(events.length).toBeGreaterThan(0);
155
+
156
+ for (const event of events) {
157
+ expect("layerId" in event).toBe(false);
158
+ expect("layerName" in event).toBe(false);
159
+ expect(ScheduleShiftUtil.getEventLayerId(event)).toBeUndefined();
160
+ expect(ScheduleShiftUtil.getEventLayerName(event)).toBeUndefined();
161
+ }
162
+ });
163
+
164
+ test("only the id is stamped when only the id is given", () => {
165
+ const layer: LayerProps = fallbackLayer(false);
166
+ layer.layerId = "only-id";
167
+
168
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents({
169
+ layers: [layer],
170
+ calendarStartDate: CAL_START,
171
+ calendarEndDate: CAL_END,
172
+ });
173
+
174
+ expect(events.length).toBeGreaterThan(0);
175
+ for (const event of events) {
176
+ expect(ScheduleShiftUtil.getEventLayerId(event)).toBe("only-id");
177
+ expect("layerName" in event).toBe(false);
178
+ }
179
+ });
180
+
181
+ test("stamping does not change the merged coverage compared to unstamped layers", () => {
182
+ const util: LayerUtil = new LayerUtil();
183
+
184
+ const stamped: Array<CalendarEvent> = util.getMultiLayerEvents({
185
+ layers: [primaryLayer(true), fallbackLayer(true)],
186
+ calendarStartDate: CAL_START,
187
+ calendarEndDate: CAL_END,
188
+ });
189
+
190
+ const plain: Array<CalendarEvent> = util.getMultiLayerEvents({
191
+ layers: [primaryLayer(false), fallbackLayer(false)],
192
+ calendarStartDate: CAL_START,
193
+ calendarEndDate: CAL_END,
194
+ });
195
+
196
+ const project: (events: Array<CalendarEvent>) => Array<string> = (
197
+ events: Array<CalendarEvent>,
198
+ ): Array<string> => {
199
+ return events.map((event: CalendarEvent) => {
200
+ return `${event.id}|${event.title}|${event.start.toISOString()}|${event.end.toISOString()}`;
201
+ });
202
+ };
203
+
204
+ expect(project(stamped)).toEqual(project(plain));
205
+ });
206
+ });
207
+
208
+ describe("LayerUtil: getEvents / getMultiLayerEvents stay identical to the *WithMeta variants", () => {
209
+ test("getEvents returns exactly getEventsWithMeta().events, never truncated by default", () => {
210
+ const util: LayerUtil = new LayerUtil();
211
+ const props: LayerProps = primaryLayer(false);
212
+
213
+ const plain: Array<CalendarEvent> = util.getEvents({
214
+ ...props,
215
+ calendarStartDate: CAL_START,
216
+ calendarEndDate: CAL_END,
217
+ });
218
+
219
+ const withMeta: LayerEventsResult = util.getEventsWithMeta({
220
+ ...props,
221
+ calendarStartDate: CAL_START,
222
+ calendarEndDate: CAL_END,
223
+ });
224
+
225
+ expect(plain.length).toBeGreaterThan(0);
226
+ expect(JSON.stringify(plain)).toBe(JSON.stringify(withMeta.events));
227
+ expect(withMeta.truncated).toBe(false);
228
+ });
229
+
230
+ test("getMultiLayerEvents returns exactly getMultiLayerEventsWithMeta().events", () => {
231
+ const util: LayerUtil = new LayerUtil();
232
+
233
+ const plain: Array<CalendarEvent> = util.getMultiLayerEvents({
234
+ layers: [primaryLayer(false), fallbackLayer(false)],
235
+ calendarStartDate: CAL_START,
236
+ calendarEndDate: CAL_END,
237
+ });
238
+
239
+ const withMeta: LayerEventsResult = util.getMultiLayerEventsWithMeta({
240
+ layers: [primaryLayer(false), fallbackLayer(false)],
241
+ calendarStartDate: CAL_START,
242
+ calendarEndDate: CAL_END,
243
+ });
244
+
245
+ expect(JSON.stringify(plain)).toBe(JSON.stringify(withMeta.events));
246
+ expect(withMeta.truncated).toBe(false);
247
+ });
248
+
249
+ test("getNumberOfEvents still caps the merged result through the meta variant", () => {
250
+ const util: LayerUtil = new LayerUtil();
251
+
252
+ const withMeta: LayerEventsResult = util.getMultiLayerEventsWithMeta(
253
+ {
254
+ layers: [primaryLayer(false), fallbackLayer(false)],
255
+ calendarStartDate: CAL_START,
256
+ calendarEndDate: CAL_END,
257
+ },
258
+ { getNumberOfEvents: 2 },
259
+ );
260
+
261
+ expect(withMeta.events).toHaveLength(2);
262
+ expect(withMeta.truncated).toBe(false);
263
+ });
264
+
265
+ test("invalid windows still yield an empty, untruncated result", () => {
266
+ const util: LayerUtil = new LayerUtil();
267
+
268
+ const result: LayerEventsResult = util.getEventsWithMeta({
269
+ ...primaryLayer(false),
270
+ calendarStartDate: CAL_END,
271
+ calendarEndDate: CAL_START,
272
+ });
273
+
274
+ expect(result.events).toEqual([]);
275
+ expect(result.truncated).toBe(false);
276
+ });
277
+ });
278
+
279
+ describe("LayerUtil: maxSimulationIterations", () => {
280
+ /*
281
+ * A restricted hourly layer that started six months before the window: the
282
+ * engine must simulate ~4,500 periods to find the rotation position at the
283
+ * window start (the restricted path cannot use the O(1) index).
284
+ */
285
+ const oldRestrictedLayer: LayerProps = {
286
+ users: [user("A"), user("B"), user("C")],
287
+ startDateTimeOfLayer: at("2025-07-01T00:00:00Z"),
288
+ restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
289
+ handOffTime: at("2025-07-01T09:00:00Z"),
290
+ rotation: rotation(EventInterval.Hour, 1),
291
+ timezone: UTC,
292
+ };
293
+
294
+ const windowStart: Date = at("2026-01-05T00:00:00Z");
295
+ const windowEnd: Date = at("2026-01-07T00:00:00Z");
296
+
297
+ test("a pre-window cap hit yields NO events for the layer and truncated=true", () => {
298
+ const result: LayerEventsResult = new LayerUtil().getEventsWithMeta(
299
+ {
300
+ ...oldRestrictedLayer,
301
+ calendarStartDate: windowStart,
302
+ calendarEndDate: windowEnd,
303
+ },
304
+ { maxSimulationIterations: 100 },
305
+ );
306
+
307
+ expect(result.truncated).toBe(true);
308
+ expect(result.events).toEqual([]);
309
+ });
310
+
311
+ test("without the option the same layer expands fully and is not truncated", () => {
312
+ const result: LayerEventsResult = new LayerUtil().getEventsWithMeta({
313
+ ...oldRestrictedLayer,
314
+ calendarStartDate: windowStart,
315
+ calendarEndDate: windowEnd,
316
+ });
317
+
318
+ expect(result.truncated).toBe(false);
319
+ expect(result.events.length).toBeGreaterThan(0);
320
+ });
321
+
322
+ test("a generous cap produces the same events as no cap", () => {
323
+ const util: LayerUtil = new LayerUtil();
324
+
325
+ const unbounded: LayerEventsResult = util.getEventsWithMeta({
326
+ ...oldRestrictedLayer,
327
+ calendarStartDate: windowStart,
328
+ calendarEndDate: windowEnd,
329
+ });
330
+
331
+ const bounded: LayerEventsResult = util.getEventsWithMeta(
332
+ {
333
+ ...oldRestrictedLayer,
334
+ calendarStartDate: windowStart,
335
+ calendarEndDate: windowEnd,
336
+ },
337
+ { maxSimulationIterations: 200000 },
338
+ );
339
+
340
+ expect(bounded.truncated).toBe(false);
341
+ expect(JSON.stringify(bounded.events)).toBe(
342
+ JSON.stringify(unbounded.events),
343
+ );
344
+ });
345
+
346
+ test("an in-window cap hit keeps the events produced so far and sets truncated", () => {
347
+ // Unrestricted (O(1) index), so the cap can only bite inside the window.
348
+ const hourlyLayer: LayerProps = {
349
+ users: [user("A"), user("B")],
350
+ startDateTimeOfLayer: windowStart,
351
+ restrictionTimes: noRestriction(),
352
+ handOffTime: at("2026-01-05T01:00:00Z"),
353
+ rotation: rotation(EventInterval.Hour, 1),
354
+ timezone: UTC,
355
+ };
356
+
357
+ const tenDaysLater: Date = at("2026-01-15T00:00:00Z");
358
+
359
+ const capped: LayerEventsResult = new LayerUtil().getEventsWithMeta(
360
+ {
361
+ ...hourlyLayer,
362
+ calendarStartDate: windowStart,
363
+ calendarEndDate: tenDaysLater,
364
+ },
365
+ { maxSimulationIterations: 50 },
366
+ );
367
+
368
+ expect(capped.truncated).toBe(true);
369
+ expect(capped.events.length).toBeGreaterThan(0);
370
+ expect(capped.events.length).toBeLessThanOrEqual(50);
371
+
372
+ const full: LayerEventsResult = new LayerUtil().getEventsWithMeta({
373
+ ...hourlyLayer,
374
+ calendarStartDate: windowStart,
375
+ calendarEndDate: tenDaysLater,
376
+ });
377
+
378
+ expect(full.truncated).toBe(false);
379
+ expect(full.events.length).toBeGreaterThan(capped.events.length);
380
+
381
+ // The events that WERE produced are a prefix of the full expansion.
382
+ for (let i: number = 0; i < capped.events.length; i++) {
383
+ expect(capped.events[i]!.title).toBe(full.events[i]!.title);
384
+ expect(capped.events[i]!.start.getTime()).toBe(
385
+ full.events[i]!.start.getTime(),
386
+ );
387
+ expect(capped.events[i]!.end.getTime()).toBe(
388
+ full.events[i]!.end.getTime(),
389
+ );
390
+ }
391
+ });
392
+
393
+ test("getMultiLayerEventsWithMeta reports truncated when ANY layer hit the cap and keeps the others", () => {
394
+ const fineLayer: LayerProps = {
395
+ users: [user("Z")],
396
+ startDateTimeOfLayer: windowStart,
397
+ restrictionTimes: noRestriction(),
398
+ handOffTime: windowStart,
399
+ rotation: rotation(EventInterval.Week, 1),
400
+ timezone: UTC,
401
+ };
402
+
403
+ const result: LayerEventsResult =
404
+ new LayerUtil().getMultiLayerEventsWithMeta(
405
+ {
406
+ layers: [oldRestrictedLayer, fineLayer],
407
+ calendarStartDate: windowStart,
408
+ calendarEndDate: windowEnd,
409
+ },
410
+ { maxSimulationIterations: 100 },
411
+ );
412
+
413
+ expect(result.truncated).toBe(true);
414
+ expect(result.events.length).toBeGreaterThan(0);
415
+ for (const event of result.events) {
416
+ expect(event.title).toBe("Z");
417
+ }
418
+ });
419
+
420
+ test("a non-positive or non-finite cap is ignored", () => {
421
+ const util: LayerUtil = new LayerUtil();
422
+
423
+ const reference: LayerEventsResult = util.getEventsWithMeta({
424
+ ...oldRestrictedLayer,
425
+ calendarStartDate: windowStart,
426
+ calendarEndDate: windowEnd,
427
+ });
428
+
429
+ for (const cap of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
430
+ const result: LayerEventsResult = util.getEventsWithMeta(
431
+ {
432
+ ...oldRestrictedLayer,
433
+ calendarStartDate: windowStart,
434
+ calendarEndDate: windowEnd,
435
+ },
436
+ { maxSimulationIterations: cap },
437
+ );
438
+
439
+ expect(result.truncated).toBe(false);
440
+ expect(JSON.stringify(result.events)).toBe(
441
+ JSON.stringify(reference.events),
442
+ );
443
+ }
444
+ });
445
+
446
+ test("the plain getMultiLayerEvents still returns the partial events under a cap", () => {
447
+ const events: Array<CalendarEvent> = new LayerUtil().getMultiLayerEvents(
448
+ {
449
+ layers: [oldRestrictedLayer],
450
+ calendarStartDate: windowStart,
451
+ calendarEndDate: windowEnd,
452
+ },
453
+ { maxSimulationIterations: 100 },
454
+ );
455
+
456
+ expect(events).toEqual([]);
457
+ });
458
+ });
@@ -0,0 +1,197 @@
1
+ import MaterializedShiftUtil, {
2
+ MaterializedShift,
3
+ MaterializedShiftJson,
4
+ } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
5
+ import { at, shift } from "./CalendarFeedTestFixtures";
6
+
7
+ describe("MaterializedShiftUtil.toJSON / fromJSON", () => {
8
+ test("round-trips a shift with every optional field", () => {
9
+ const full: MaterializedShift = shift({
10
+ start: at("2026-09-01T07:00:00Z"),
11
+ end: at("2026-09-01T15:00:00Z"),
12
+ projectName: "Acme",
13
+ layerId: "layer-1",
14
+ layerName: "Primary",
15
+ override: {
16
+ originalUserId: "user-b",
17
+ originalUserName: "Bob",
18
+ overrideStartsAt: at("2026-09-01T06:00:00Z"),
19
+ overrideEndsAt: at("2026-09-01T18:00:00Z"),
20
+ onCallDutyPolicyId: "pol-1",
21
+ },
22
+ policyVariantOf: {
23
+ policyId: "pol-1",
24
+ policyName: "Payments Policy",
25
+ globalUserId: "user-b",
26
+ },
27
+ isPast: true,
28
+ });
29
+
30
+ const json: MaterializedShiftJson = MaterializedShiftUtil.toJSON(full);
31
+
32
+ expect(json.start).toBe("2026-09-01T07:00:00.000Z");
33
+ expect(json.end).toBe("2026-09-01T15:00:00.000Z");
34
+ expect(json.lastModifiedAt).toBe("2026-08-01T10:00:00.000Z");
35
+ expect(json.scheduleTimezone).toBe("Europe/Stockholm");
36
+ expect(json.override!.overrideStartsAt).toBe("2026-09-01T06:00:00.000Z");
37
+ expect(json.override!.onCallDutyPolicyId).toBe("pol-1");
38
+ expect(json.policyVariantOf).toEqual(full.policyVariantOf);
39
+
40
+ // It is plain JSON: a serialize/parse cycle changes nothing.
41
+ const reparsed: MaterializedShiftJson = JSON.parse(JSON.stringify(json));
42
+ const back: MaterializedShift = MaterializedShiftUtil.fromJSON(reparsed);
43
+
44
+ expect(back).toEqual(full);
45
+ expect(back.start).toBeInstanceOf(Date);
46
+ expect(back.override!.overrideEndsAt).toBeInstanceOf(Date);
47
+ });
48
+
49
+ test("omits optional keys that are absent instead of writing undefined", () => {
50
+ const minimal: MaterializedShift = shift({
51
+ start: at("2026-09-01T07:00:00Z"),
52
+ end: at("2026-09-01T15:00:00Z"),
53
+ scheduleTimezone: undefined,
54
+ });
55
+
56
+ const json: MaterializedShiftJson = MaterializedShiftUtil.toJSON(minimal);
57
+
58
+ expect("projectName" in json).toBe(false);
59
+ expect("layerId" in json).toBe(false);
60
+ expect("layerName" in json).toBe(false);
61
+ expect("override" in json).toBe(false);
62
+ expect("policyVariantOf" in json).toBe(false);
63
+ expect(json.scheduleTimezone).toBeNull();
64
+
65
+ const back: MaterializedShift = MaterializedShiftUtil.fromJSON(json);
66
+
67
+ expect("scheduleTimezone" in back).toBe(false);
68
+ expect("override" in back).toBe(false);
69
+ expect("layerId" in back).toBe(false);
70
+ expect(back).toEqual(minimal);
71
+ });
72
+
73
+ test("a global override has no onCallDutyPolicyId key on either side", () => {
74
+ const withGlobal: MaterializedShift = shift({
75
+ start: at("2026-09-01T07:00:00Z"),
76
+ end: at("2026-09-01T15:00:00Z"),
77
+ override: {
78
+ originalUserId: "user-b",
79
+ originalUserName: "Bob",
80
+ overrideStartsAt: at("2026-09-01T06:00:00Z"),
81
+ overrideEndsAt: at("2026-09-01T18:00:00Z"),
82
+ },
83
+ });
84
+
85
+ const json: MaterializedShiftJson =
86
+ MaterializedShiftUtil.toJSON(withGlobal);
87
+ expect("onCallDutyPolicyId" in json.override!).toBe(false);
88
+
89
+ const back: MaterializedShift = MaterializedShiftUtil.fromJSON(json);
90
+ expect("onCallDutyPolicyId" in back.override!).toBe(false);
91
+ });
92
+
93
+ test("toJSON copies the policies array so later mutation does not leak", () => {
94
+ const source: MaterializedShift = shift({
95
+ start: at("2026-09-01T07:00:00Z"),
96
+ end: at("2026-09-01T15:00:00Z"),
97
+ });
98
+
99
+ const json: MaterializedShiftJson = MaterializedShiftUtil.toJSON(source);
100
+ json.policies[0]!.policyName = "changed";
101
+
102
+ expect(source.policies[0]!.policyName).toBe("Payments Policy");
103
+ });
104
+
105
+ test("fromJSON tolerates a missing policies array", () => {
106
+ const json: MaterializedShiftJson = MaterializedShiftUtil.toJSON(
107
+ shift({
108
+ start: at("2026-09-01T07:00:00Z"),
109
+ end: at("2026-09-01T15:00:00Z"),
110
+ }),
111
+ );
112
+ delete (json as Partial<MaterializedShiftJson>).policies;
113
+
114
+ expect(MaterializedShiftUtil.fromJSON(json).policies).toEqual([]);
115
+ });
116
+
117
+ test("array helpers map element-wise", () => {
118
+ const shifts: Array<MaterializedShift> = [
119
+ shift({
120
+ start: at("2026-09-01T07:00:00Z"),
121
+ end: at("2026-09-01T15:00:00Z"),
122
+ }),
123
+ shift({
124
+ start: at("2026-09-02T07:00:00Z"),
125
+ end: at("2026-09-02T15:00:00Z"),
126
+ userId: "user-b",
127
+ userName: "Bob",
128
+ }),
129
+ ];
130
+
131
+ const json: Array<MaterializedShiftJson> =
132
+ MaterializedShiftUtil.toJSONArray(shifts);
133
+ expect(json).toHaveLength(2);
134
+ expect(json[1]!.userId).toBe("user-b");
135
+
136
+ expect(MaterializedShiftUtil.fromJSONArray(json)).toEqual(shifts);
137
+ });
138
+ });
139
+
140
+ describe("MaterializedShiftUtil.sortByStart", () => {
141
+ test("orders by start, then schedule id, then shift key, without mutating the input", () => {
142
+ const later: MaterializedShift = shift({
143
+ start: at("2026-09-02T07:00:00Z"),
144
+ end: at("2026-09-02T15:00:00Z"),
145
+ });
146
+ const schedB: MaterializedShift = shift({
147
+ start: at("2026-09-01T07:00:00Z"),
148
+ end: at("2026-09-01T15:00:00Z"),
149
+ scheduleId: "sched-b",
150
+ });
151
+ const schedA: MaterializedShift = shift({
152
+ start: at("2026-09-01T07:00:00Z"),
153
+ end: at("2026-09-01T15:00:00Z"),
154
+ scheduleId: "sched-a",
155
+ });
156
+ const schedAVariant: MaterializedShift = shift({
157
+ start: at("2026-09-01T07:00:00Z"),
158
+ end: at("2026-09-01T15:00:00Z"),
159
+ scheduleId: "sched-a",
160
+ policyVariantOf: {
161
+ policyId: "pol-9",
162
+ policyName: "P9",
163
+ globalUserId: "user-a",
164
+ },
165
+ });
166
+
167
+ const input: Array<MaterializedShift> = [
168
+ later,
169
+ schedB,
170
+ schedAVariant,
171
+ schedA,
172
+ ];
173
+ const sorted: Array<MaterializedShift> =
174
+ MaterializedShiftUtil.sortByStart(input);
175
+
176
+ expect(sorted).toEqual([schedA, schedAVariant, schedB, later]);
177
+ expect(input).toEqual([later, schedB, schedAVariant, schedA]);
178
+ });
179
+ });
180
+
181
+ describe("MaterializedShiftUtil.buildShiftKey", () => {
182
+ test("is schedule:epochSeconds, plus the policy id for variants", () => {
183
+ const start: Date = at("2026-09-01T07:00:00.900Z");
184
+ const epoch: number = Math.floor(start.getTime() / 1000);
185
+
186
+ expect(
187
+ MaterializedShiftUtil.buildShiftKey({ scheduleId: "s1", start }),
188
+ ).toBe(`s1:${epoch}`);
189
+ expect(
190
+ MaterializedShiftUtil.buildShiftKey({
191
+ scheduleId: "s1",
192
+ start,
193
+ policyId: "p1",
194
+ }),
195
+ ).toBe(`s1:${epoch}:p1`);
196
+ });
197
+ });