@oneuptime/common 11.3.26 → 11.3.28

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 (241) hide show
  1. package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
  2. package/Models/DatabaseModels/IncidentMember.ts +1 -1
  3. package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
  4. package/Models/DatabaseModels/Index.ts +3 -0
  5. package/Models/DatabaseModels/MigrationFailure.ts +170 -0
  6. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
  7. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
  8. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
  9. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
  10. package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
  11. package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
  12. package/Server/API/AlertAPI.ts +1 -1
  13. package/Server/API/IncidentAPI.ts +2 -2
  14. package/Server/API/IncidentEpisodeAPI.ts +1 -1
  15. package/Server/API/LlmProviderAPI.ts +1 -0
  16. package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  22. package/Server/Infrastructure/PostgresDatabase.ts +11 -0
  23. package/Server/Services/AIService.ts +74 -2
  24. package/Server/Services/AnalyticsDatabaseService.ts +10 -1
  25. package/Server/Services/DatabaseService.ts +10 -1
  26. package/Server/Services/IncidentService.ts +1 -1
  27. package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
  28. package/Server/Services/IncomingCallPolicyService.ts +42 -1
  29. package/Server/Services/Index.ts +2 -0
  30. package/Server/Services/MigrationFailureService.ts +9 -0
  31. package/Server/Services/MonitorProbeService.ts +33 -7
  32. package/Server/Services/MonitorService.ts +19 -7
  33. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
  34. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
  35. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
  36. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
  37. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
  38. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
  39. package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
  40. package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
  41. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
  42. package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
  43. package/Server/Services/TeamMemberService.ts +6 -0
  44. package/Server/Services/UserNotificationRuleService.ts +13 -42
  45. package/Server/Services/UserOnCallLogService.ts +90 -0
  46. package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
  47. package/Server/Types/Database/QueryUtil.ts +57 -0
  48. package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
  49. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +14 -2
  50. package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
  51. package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
  52. package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
  53. package/Server/Utils/AI/Toolbox/IncidentTools.ts +183 -1
  54. package/Server/Utils/AI/Toolbox/Index.ts +4 -1
  55. package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
  56. package/Server/Utils/AI/Toolbox/Serializer.ts +42 -0
  57. package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
  58. package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
  59. package/Server/Utils/LLM/LLMService.ts +110 -11
  60. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
  61. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
  62. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
  63. package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
  64. package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
  65. package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
  66. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
  67. package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
  68. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +78 -0
  69. package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +68 -0
  70. package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
  71. package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
  72. package/Tests/Types/Events/Recurring.test.ts +22 -9
  73. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
  74. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
  75. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
  76. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
  77. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
  78. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
  79. package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
  80. package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
  82. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
  83. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
  84. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
  85. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
  86. package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
  87. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
  88. package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
  89. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
  90. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
  91. package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
  92. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
  93. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
  94. package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
  95. package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
  96. package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
  97. package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
  98. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
  99. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
  100. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
  101. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
  102. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
  103. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
  104. package/Types/Date.ts +200 -15
  105. package/Types/OnCallDutyPolicy/Layer.ts +790 -260
  106. package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
  107. package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
  108. package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
  109. package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
  110. package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
  111. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
  112. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
  114. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
  116. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  118. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
  120. package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
  121. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
  122. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
  124. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  125. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
  126. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  127. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
  128. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
  129. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
  130. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
  131. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
  132. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
  133. package/build/dist/Server/API/AlertAPI.js +1 -1
  134. package/build/dist/Server/API/IncidentAPI.js +2 -2
  135. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  136. package/build/dist/Server/API/LlmProviderAPI.js +1 -0
  137. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  138. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  139. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  149. package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
  150. package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
  151. package/build/dist/Server/Services/AIService.js +53 -2
  152. package/build/dist/Server/Services/AIService.js.map +1 -1
  153. package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
  154. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  155. package/build/dist/Server/Services/DatabaseService.js +10 -1
  156. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  157. package/build/dist/Server/Services/IncidentService.js +1 -1
  158. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
  159. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
  160. package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
  161. package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
  162. package/build/dist/Server/Services/Index.js +2 -0
  163. package/build/dist/Server/Services/Index.js.map +1 -1
  164. package/build/dist/Server/Services/MigrationFailureService.js +9 -0
  165. package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
  166. package/build/dist/Server/Services/MonitorProbeService.js +33 -7
  167. package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
  168. package/build/dist/Server/Services/MonitorService.js +12 -5
  169. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  170. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
  171. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  172. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
  173. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  174. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
  175. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  176. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
  177. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  178. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
  179. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  180. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
  181. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  182. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
  183. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  184. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
  185. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
  186. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
  187. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  188. package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
  189. package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
  190. package/build/dist/Server/Services/TeamMemberService.js +6 -0
  191. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  192. package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
  193. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  194. package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
  195. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  196. package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
  197. package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
  198. package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
  199. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  200. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
  201. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
  202. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +13 -2
  203. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  204. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
  205. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
  206. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
  207. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
  208. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
  209. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
  210. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +152 -1
  211. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  212. package/build/dist/Server/Utils/AI/Toolbox/Index.js +4 -1
  213. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  214. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
  215. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
  216. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +40 -0
  217. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -1
  218. package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
  219. package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
  220. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
  221. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
  222. package/build/dist/Server/Utils/LLM/LLMService.js +81 -5
  223. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  224. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
  225. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  226. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
  227. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  228. package/build/dist/Types/Date.js +153 -15
  229. package/build/dist/Types/Date.js.map +1 -1
  230. package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
  231. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  232. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
  233. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
  234. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
  235. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
  236. package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
  237. package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
  238. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  239. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
  240. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
  241. package/package.json +1 -1
@@ -0,0 +1,1095 @@
1
+ /**
2
+ * EXHAUSTIVE coverage for the WEEKLY restriction expansion path of LayerUtil:
3
+ * - getWeeklyRestrictionTimesForWeek (segment construction, wrap split, week
4
+ * anchoring — audit F6)
5
+ * - getEventsByWeeklyRestriction (weekly tiling across a multi-week rotation
6
+ * event + merge of overlapping/duplicate segments — audit F3)
7
+ * - mergeOverlappingStartAndEndTimes (exercised through the above)
8
+ * - end-to-end getEvents: single non-wrapping window (Mon-Fri), wrap-around
9
+ * weekend (Fri 20:00 -> Mon 08:00), multiple windows in one week (disjoint
10
+ * AND overlapping), a window spanning (almost) the whole week, a rotation
11
+ * event spanning several ISO weeks tiled with no duplicate/overlap, DST weeks
12
+ * (America/New_York) whose wall-clock boundaries (incl. the weekend Monday
13
+ * close) do not drift, and resolving on the weekend gap returning the correct
14
+ * next-week user (audit F2 weekly).
15
+ *
16
+ * Every timezone-sensitive assertion is expressed in the SCHEDULE zone's
17
+ * wall-clock via moment-timezone, so the file is correct under any process TZ
18
+ * (run it a second time under TZ=UTC to exercise the exact server-vs-schedule
19
+ * divergence the DST fixes target). "Plain" scenarios use an explicit "UTC"
20
+ * schedule zone (no DST) so they are deterministic regardless of the process
21
+ * zone; DST scenarios use America/New_York, which — against a UTC or London
22
+ * process zone — reproduces the server-vs-schedule divergence.
23
+ *
24
+ * The core oracle: a wall-clock coverage model computed INDEPENDENTLY of the
25
+ * production code (weekday + time-of-day in the schedule zone, with wrap-around
26
+ * handling) is compared against the code's emitted events at hundreds of sampled
27
+ * instants per config. Boundaries are guarded by a small epsilon so a +-1s edge
28
+ * adjustment in the code is never a false positive.
29
+ */
30
+ import LayerUtil, { LayerProps } from "../../../Types/OnCallDutyPolicy/Layer";
31
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
32
+ import RestrictionTimes, {
33
+ RestrictionType,
34
+ WeeklyResctriction,
35
+ } from "../../../Types/OnCallDutyPolicy/RestrictionTimes";
36
+ import Recurring from "../../../Types/Events/Recurring";
37
+ import OneUptimeDate from "../../../Types/Date";
38
+ import User from "../../../Models/DatabaseModels/User";
39
+ import EventInterval from "../../../Types/Events/EventInterval";
40
+ import PositiveNumber from "../../../Types/PositiveNumber";
41
+ import StartAndEndTime from "../../../Types/Time/StartAndEndTime";
42
+ import moment from "moment-timezone";
43
+
44
+ /*
45
+ * ---------------------------------------------------------------------------
46
+ * Fixtures / builders
47
+ * ---------------------------------------------------------------------------
48
+ */
49
+
50
+ function user(id: string): User {
51
+ return {
52
+ id: {
53
+ toString: (): string => {
54
+ return id;
55
+ },
56
+ } as any,
57
+ } as User;
58
+ }
59
+
60
+ const UTC: string = "UTC";
61
+ const NY: string = "America/New_York";
62
+
63
+ // An absolute instant that reads `iso` (YYYY-MM-DD HH:mm[:ss]) wall-clock in `zone`.
64
+ function at(iso: string, zone: string): Date {
65
+ return moment.tz(iso, zone).toDate();
66
+ }
67
+
68
+ /*
69
+ * A weekly restriction window authored by its wall-clock start/end in `zone`.
70
+ * The production code derives the day-of-week from the Date itself (startDay /
71
+ * endDay fields are informational), so the Date must fall on the intended day.
72
+ */
73
+ function win(
74
+ startISO: string,
75
+ endISO: string,
76
+ zone: string,
77
+ ): WeeklyResctriction {
78
+ const startTime: Date = at(startISO, zone);
79
+ const endTime: Date = at(endISO, zone);
80
+ return {
81
+ startDay: OneUptimeDate.getDayOfWeek(startTime, zone),
82
+ endDay: OneUptimeDate.getDayOfWeek(endTime, zone),
83
+ startTime,
84
+ endTime,
85
+ };
86
+ }
87
+
88
+ function weeklyRestriction(
89
+ windows: Array<WeeklyResctriction>,
90
+ ): RestrictionTimes {
91
+ const r: RestrictionTimes = new RestrictionTimes();
92
+ r.restictionType = RestrictionType.Weekly;
93
+ r.weeklyRestrictionTimes = windows;
94
+ return r;
95
+ }
96
+
97
+ function weeklyLayer(opts: {
98
+ users: Array<string>;
99
+ zone: string | undefined;
100
+ windows: Array<WeeklyResctriction>;
101
+ layerStart: Date;
102
+ handOffTime: Date;
103
+ rotationType: EventInterval;
104
+ rotationCount: number;
105
+ }): LayerProps {
106
+ const rot: Recurring = new Recurring();
107
+ rot.intervalType = opts.rotationType;
108
+ rot.intervalCount = new PositiveNumber(opts.rotationCount);
109
+ return {
110
+ users: opts.users.map(user),
111
+ startDateTimeOfLayer: opts.layerStart,
112
+ restrictionTimes: weeklyRestriction(opts.windows),
113
+ handOffTime: opts.handOffTime,
114
+ rotation: rot,
115
+ timezone: opts.zone,
116
+ };
117
+ }
118
+
119
+ function expand(
120
+ layer: LayerProps,
121
+ start: Date,
122
+ end: Date,
123
+ ): Array<CalendarEvent> {
124
+ return new LayerUtil().getEvents({
125
+ ...layer,
126
+ calendarStartDate: start,
127
+ calendarEndDate: end,
128
+ });
129
+ }
130
+
131
+ /*
132
+ * A LayerUtil whose per-call schedule zone is pinned, so the *public* restriction
133
+ * helpers can be unit-tested directly (getEvents normally sets this internally).
134
+ */
135
+ function utilInZone(zone: string): LayerUtil {
136
+ const u: LayerUtil = new LayerUtil();
137
+ (u as unknown as { timezone: string | undefined }).timezone = zone;
138
+ return u;
139
+ }
140
+
141
+ /*
142
+ * ---------------------------------------------------------------------------
143
+ * Independent wall-clock coverage ORACLE
144
+ * ---------------------------------------------------------------------------
145
+ */
146
+
147
+ const WEEK_SECONDS: number = 7 * 24 * 3600;
148
+
149
+ /*
150
+ * Seconds elapsed from the start of the (Sunday-anchored) week, in `zone`
151
+ * wall-clock. Sunday 00:00 => 0, Saturday 23:59:59 => 604799.
152
+ */
153
+ function weekSecondOf(instant: Date, zone: string): number {
154
+ const m: moment.Moment = moment.tz(instant, zone);
155
+ const sundayIndex: number = m.isoWeekday() % 7; // Mon=1..Sun=7 -> Sun=0..Sat=6
156
+ return sundayIndex * 86400 + m.hour() * 3600 + m.minute() * 60 + m.second();
157
+ }
158
+
159
+ interface OracleWindow {
160
+ startWS: number;
161
+ endWS: number;
162
+ }
163
+
164
+ function oracleWindow(w: WeeklyResctriction, zone: string): OracleWindow {
165
+ return {
166
+ startWS: weekSecondOf(w.startTime, zone),
167
+ endWS: weekSecondOf(w.endTime, zone),
168
+ };
169
+ }
170
+
171
+ function coveredByOracle(ws: number, windows: Array<OracleWindow>): boolean {
172
+ for (const w of windows) {
173
+ if (w.startWS <= w.endWS) {
174
+ if (ws >= w.startWS && ws < w.endWS) {
175
+ return true;
176
+ }
177
+ } else if (ws >= w.startWS || ws < w.endWS) {
178
+ // wrap-around window (e.g. Fri 20:00 -> Mon 08:00)
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+
185
+ function circularDistance(a: number, b: number): number {
186
+ let d: number = Math.abs(a - b) % WEEK_SECONDS;
187
+ if (d > WEEK_SECONDS / 2) {
188
+ d = WEEK_SECONDS - d;
189
+ }
190
+ return d;
191
+ }
192
+
193
+ function nearAnyBoundary(
194
+ ws: number,
195
+ windows: Array<OracleWindow>,
196
+ guardSeconds: number,
197
+ ): boolean {
198
+ for (const w of windows) {
199
+ if (circularDistance(ws, w.startWS) < guardSeconds) {
200
+ return true;
201
+ }
202
+ if (circularDistance(ws, w.endWS) < guardSeconds) {
203
+ return true;
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+
209
+ function coveredByEvents(events: Array<CalendarEvent>, t: Date): boolean {
210
+ const ms: number = t.getTime();
211
+ return events.some((e: CalendarEvent) => {
212
+ return ms >= e.start.getTime() && ms < e.end.getTime();
213
+ });
214
+ }
215
+
216
+ function coveringEvent(
217
+ events: Array<CalendarEvent>,
218
+ t: Date,
219
+ ): CalendarEvent | undefined {
220
+ const ms: number = t.getTime();
221
+ return events.find((e: CalendarEvent) => {
222
+ return ms >= e.start.getTime() && ms < e.end.getTime();
223
+ });
224
+ }
225
+
226
+ /*
227
+ * Sample the calendar window at a fixed real-time step and compare code coverage
228
+ * vs oracle coverage. Returns a list of human-readable mismatches (empty == pass).
229
+ */
230
+ function coverageMismatches(opts: {
231
+ events: Array<CalendarEvent>;
232
+ windows: Array<WeeklyResctriction>;
233
+ zone: string;
234
+ start: Date;
235
+ end: Date;
236
+ stepSeconds: number;
237
+ guardSeconds: number;
238
+ }): { samples: number; mismatches: Array<string> } {
239
+ const oracle: Array<OracleWindow> = opts.windows.map(
240
+ (w: WeeklyResctriction) => {
241
+ return oracleWindow(w, opts.zone);
242
+ },
243
+ );
244
+ const mismatches: Array<string> = [];
245
+ let samples: number = 0;
246
+
247
+ // Non-round phase offset so samples don't systematically land on :00 boundaries.
248
+ let cursorMs: number = opts.start.getTime() + 613 * 1000;
249
+ const endMs: number = opts.end.getTime();
250
+
251
+ while (cursorMs < endMs) {
252
+ const t: Date = new Date(cursorMs);
253
+ const ws: number = weekSecondOf(t, opts.zone);
254
+ if (!nearAnyBoundary(ws, oracle, opts.guardSeconds)) {
255
+ const codeCovered: boolean = coveredByEvents(opts.events, t);
256
+ const oracleCovered: boolean = coveredByOracle(ws, oracle);
257
+ if (codeCovered !== oracleCovered) {
258
+ mismatches.push(
259
+ `t=${t.toISOString()} (${moment.tz(t, opts.zone).format("ddd HH:mm")} ${opts.zone}) code=${codeCovered} oracle=${oracleCovered}`,
260
+ );
261
+ }
262
+ samples++;
263
+ }
264
+ cursorMs += opts.stepSeconds * 1000;
265
+ }
266
+
267
+ return { samples, mismatches };
268
+ }
269
+
270
+ // For single-user layers, events must never overlap and never duplicate.
271
+ function overlapProblems(events: Array<CalendarEvent>): Array<string> {
272
+ const sorted: Array<CalendarEvent> = [...events].sort(
273
+ (a: CalendarEvent, b: CalendarEvent) => {
274
+ return a.start.getTime() - b.start.getTime();
275
+ },
276
+ );
277
+ const problems: Array<string> = [];
278
+ for (let i: number = 0; i < sorted.length; i++) {
279
+ const cur: CalendarEvent | undefined = sorted[i];
280
+ if (!cur) {
281
+ continue;
282
+ }
283
+ if (cur.end.getTime() <= cur.start.getTime()) {
284
+ problems.push(`zero/negative length @${cur.start.toISOString()}`);
285
+ }
286
+ if (i > 0) {
287
+ const prev: CalendarEvent | undefined = sorted[i - 1];
288
+ if (prev && cur.start.getTime() < prev.end.getTime()) {
289
+ problems.push(
290
+ `overlap: [${prev.start.toISOString()}->${prev.end.toISOString()}] & [${cur.start.toISOString()}->${cur.end.toISOString()}]`,
291
+ );
292
+ }
293
+ if (
294
+ prev &&
295
+ prev.start.getTime() === cur.start.getTime() &&
296
+ prev.end.getTime() === cur.end.getTime()
297
+ ) {
298
+ problems.push(`duplicate @${cur.start.toISOString()}`);
299
+ }
300
+ }
301
+ }
302
+ return problems;
303
+ }
304
+
305
+ // A clean anchor: layer starts on a Sunday 00:00 so the first ISO week is whole.
306
+ const UTC_SUN_JAN5: Date = at("2025-01-05 00:00", UTC);
307
+
308
+ /*
309
+ * ===========================================================================
310
+ * UNIT: getWeeklyRestrictionTimesForWeek — segment construction
311
+ * ===========================================================================
312
+ */
313
+
314
+ describe("getWeeklyRestrictionTimesForWeek: segment construction", () => {
315
+ test("non-wrapping Mon-Fri window emits exactly ONE ordered segment (no phantom/inverted extra)", () => {
316
+ const util: LayerUtil = utilInZone(UTC);
317
+ // Mon 00:00 -> Sat 00:00 (covers Mon..Fri).
318
+ const restriction: RestrictionTimes = weeklyRestriction([
319
+ win("2025-01-06 00:00", "2025-01-11 00:00", UTC),
320
+ ]);
321
+
322
+ const segments: Array<StartAndEndTime> =
323
+ util.getWeeklyRestrictionTimesForWeek({
324
+ eventStartTime: at("2025-01-08 12:00", UTC), // a Wednesday
325
+ eventEndTime: at("2025-01-08 23:59", UTC),
326
+ restrictionTimes: restriction,
327
+ });
328
+
329
+ expect(segments.length).toBe(1);
330
+ const seg: StartAndEndTime = segments[0]!;
331
+ // Boundaries move to the event's own week, keeping wall-clock day+time.
332
+ expect(moment.tz(seg.startTime, UTC).format("ddd HH:mm")).toBe("Mon 00:00");
333
+ expect(moment.tz(seg.endTime, UTC).format("ddd HH:mm")).toBe("Sat 00:00");
334
+ expect(seg.startTime.getTime()).toBeLessThan(seg.endTime.getTime());
335
+ });
336
+
337
+ test("wrap-around weekend (Fri 20:00 -> Mon 08:00) splits into head + main, both ordered", () => {
338
+ const util: LayerUtil = utilInZone(UTC);
339
+ const restriction: RestrictionTimes = weeklyRestriction([
340
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC), // Fri 20:00 -> Mon 08:00
341
+ ]);
342
+
343
+ const segments: Array<StartAndEndTime> =
344
+ util.getWeeklyRestrictionTimesForWeek({
345
+ eventStartTime: at("2025-01-08 12:00", UTC), // Wednesday of the week
346
+ eventEndTime: at("2025-01-08 23:59", UTC),
347
+ restrictionTimes: restriction,
348
+ });
349
+
350
+ expect(segments.length).toBe(2);
351
+ const sorted: Array<StartAndEndTime> = [...segments].sort(
352
+ (a: StartAndEndTime, b: StartAndEndTime) => {
353
+ return a.startTime.getTime() - b.startTime.getTime();
354
+ },
355
+ );
356
+ const head: StartAndEndTime = sorted[0]!;
357
+ const main: StartAndEndTime = sorted[1]!;
358
+
359
+ // Head: the early-week tail (start of the Sunday-anchored week -> Mon 08:00).
360
+ expect(moment.tz(head.startTime, UTC).format("ddd HH:mm")).toBe(
361
+ "Sun 00:00",
362
+ );
363
+ expect(moment.tz(head.endTime, UTC).format("ddd HH:mm")).toBe("Mon 08:00");
364
+ // The head start equals getStartOfTheWeek of the event (audit F6 anchoring).
365
+ expect(head.startTime.getTime()).toBe(
366
+ OneUptimeDate.getStartOfTheWeek(
367
+ at("2025-01-08 12:00", UTC),
368
+ UTC,
369
+ ).getTime(),
370
+ );
371
+
372
+ // Main: Fri 20:00 -> the FOLLOWING Monday 08:00 (endTime + 7 days).
373
+ expect(moment.tz(main.startTime, UTC).format("ddd HH:mm")).toBe(
374
+ "Fri 20:00",
375
+ );
376
+ expect(moment.tz(main.endTime, UTC).format("ddd HH:mm")).toBe("Mon 08:00");
377
+ // main.end is a full 7 days after the head's Monday close (wall-clock).
378
+ expect(
379
+ moment.tz(main.endTime, UTC).diff(moment.tz(head.endTime, UTC), "days"),
380
+ ).toBe(7);
381
+
382
+ // Neither segment is inverted.
383
+ for (const s of segments) {
384
+ expect(s.startTime.getTime()).toBeLessThan(s.endTime.getTime());
385
+ }
386
+ });
387
+
388
+ test("resolving MID-WEEKEND does not invert the head segment (audit F6)", () => {
389
+ const util: LayerUtil = utilInZone(UTC);
390
+ const restriction: RestrictionTimes = weeklyRestriction([
391
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC), // Fri 20:00 -> Mon 08:00
392
+ ]);
393
+
394
+ // "now" is Sunday 04:00 — already past the window's Monday-of-this-week end.
395
+ const segments: Array<StartAndEndTime> =
396
+ util.getWeeklyRestrictionTimesForWeek({
397
+ eventStartTime: at("2025-01-12 04:00", UTC), // Sunday
398
+ eventEndTime: at("2025-01-12 06:00", UTC),
399
+ restrictionTimes: restriction,
400
+ });
401
+
402
+ // No segment may be inverted (the pre-fix bug produced start > end here).
403
+ for (const s of segments) {
404
+ expect(s.startTime.getTime()).toBeLessThanOrEqual(s.endTime.getTime());
405
+ }
406
+ // Head must still be anchored to the Sunday week-start, ordered before its end.
407
+ const head: StartAndEndTime | undefined = segments.find(
408
+ (s: StartAndEndTime) => {
409
+ return moment.tz(s.startTime, UTC).format("ddd HH:mm") === "Sun 00:00";
410
+ },
411
+ );
412
+ expect(head).toBeTruthy();
413
+ expect(moment.tz(head!.endTime, UTC).format("ddd HH:mm")).toBe("Mon 08:00");
414
+ });
415
+ });
416
+
417
+ /*
418
+ * ===========================================================================
419
+ * UNIT: getEventsByWeeklyRestriction — tiling + merge
420
+ * ===========================================================================
421
+ */
422
+
423
+ describe("getEventsByWeeklyRestriction: merge of overlapping / duplicate segments", () => {
424
+ test("two OVERLAPPING same-week windows merge into one contiguous block (no overlap/dupe)", () => {
425
+ const util: LayerUtil = utilInZone(UTC);
426
+ const restriction: RestrictionTimes = weeklyRestriction([
427
+ win("2025-01-06 00:00", "2025-01-08 12:00", UTC), // Mon 00:00 -> Wed 12:00
428
+ win("2025-01-08 10:00", "2025-01-10 00:00", UTC), // Wed 10:00 -> Fri 00:00
429
+ ]);
430
+
431
+ const merged: Array<StartAndEndTime> = util.getEventsByWeeklyRestriction({
432
+ eventStartTime: at("2025-01-06 00:00", UTC),
433
+ eventEndTime: at("2025-01-10 00:00", UTC),
434
+ restrictionTimes: restriction,
435
+ });
436
+
437
+ expect(merged.length).toBe(1);
438
+ expect(moment.tz(merged[0]!.startTime, UTC).format("ddd HH:mm")).toBe(
439
+ "Mon 00:00",
440
+ );
441
+ expect(moment.tz(merged[0]!.endTime, UTC).format("ddd HH:mm")).toBe(
442
+ "Fri 00:00",
443
+ );
444
+ });
445
+
446
+ test("two DISJOINT windows stay separate (the real Thursday gap is preserved)", () => {
447
+ const util: LayerUtil = utilInZone(UTC);
448
+ const restriction: RestrictionTimes = weeklyRestriction([
449
+ win("2025-01-06 00:00", "2025-01-08 00:00", UTC), // Mon -> Wed
450
+ win("2025-01-10 00:00", "2025-01-11 00:00", UTC), // Fri -> Sat
451
+ ]);
452
+
453
+ const segments: Array<StartAndEndTime> = util.getEventsByWeeklyRestriction({
454
+ eventStartTime: at("2025-01-06 00:00", UTC),
455
+ eventEndTime: at("2025-01-11 00:00", UTC),
456
+ restrictionTimes: restriction,
457
+ });
458
+
459
+ expect(segments.length).toBe(2);
460
+ const sorted: Array<StartAndEndTime> = [...segments].sort(
461
+ (a: StartAndEndTime, b: StartAndEndTime) => {
462
+ return a.startTime.getTime() - b.startTime.getTime();
463
+ },
464
+ );
465
+ expect(moment.tz(sorted[0]!.endTime, UTC).format("ddd HH:mm")).toBe(
466
+ "Wed 00:00",
467
+ );
468
+ expect(moment.tz(sorted[1]!.startTime, UTC).format("ddd HH:mm")).toBe(
469
+ "Fri 00:00",
470
+ );
471
+ // There is a genuine (Thursday) gap between them.
472
+ expect(sorted[1]!.startTime.getTime()).toBeGreaterThan(
473
+ sorted[0]!.endTime.getTime(),
474
+ );
475
+ });
476
+
477
+ test("empty weeklyRestrictionTimes returns the full event window unchanged", () => {
478
+ const util: LayerUtil = utilInZone(UTC);
479
+ const restriction: RestrictionTimes = weeklyRestriction([]);
480
+ const eventStart: Date = at("2025-01-06 00:00", UTC);
481
+ const eventEnd: Date = at("2025-01-08 00:00", UTC);
482
+
483
+ const segments: Array<StartAndEndTime> = util.getEventsByWeeklyRestriction({
484
+ eventStartTime: eventStart,
485
+ eventEndTime: eventEnd,
486
+ restrictionTimes: restriction,
487
+ });
488
+
489
+ expect(segments.length).toBe(1);
490
+ expect(segments[0]!.startTime.getTime()).toBe(eventStart.getTime());
491
+ expect(segments[0]!.endTime.getTime()).toBe(eventEnd.getTime());
492
+ });
493
+
494
+ test("multi-week wrap tiling produces NO duplicate/overlapping segment on the Sun->Mon seam (audit F3)", () => {
495
+ const util: LayerUtil = utilInZone(UTC);
496
+ const restriction: RestrictionTimes = weeklyRestriction([
497
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC), // Fri 20:00 -> Mon 08:00
498
+ ]);
499
+
500
+ // One "event" (as a monthly rotation period would be) spanning ~4 ISO weeks.
501
+ const segments: Array<StartAndEndTime> = util.getEventsByWeeklyRestriction({
502
+ eventStartTime: UTC_SUN_JAN5,
503
+ eventEndTime: at("2025-02-02 00:00", UTC),
504
+ restrictionTimes: restriction,
505
+ });
506
+
507
+ // No two merged segments may overlap or duplicate.
508
+ const asEvents: Array<CalendarEvent> = segments.map(
509
+ (s: StartAndEndTime) => {
510
+ return {
511
+ id: 0,
512
+ title: "x",
513
+ allDay: false,
514
+ start: s.startTime,
515
+ end: s.endTime,
516
+ };
517
+ },
518
+ );
519
+ expect(overlapProblems(asEvents)).toEqual([]);
520
+
521
+ // Each interior weekend must be a single contiguous Fri 20:00 -> Mon 08:00 block.
522
+ const jan18: Date = at("2025-01-18 12:00", UTC); // a Saturday
523
+ const cover: CalendarEvent | undefined = coveringEvent(asEvents, jan18);
524
+ expect(cover).toBeTruthy();
525
+ expect(moment.tz(cover!.start, UTC).format("ddd HH:mm")).toBe("Fri 20:00");
526
+ expect(moment.tz(cover!.end, UTC).format("ddd HH:mm")).toBe("Mon 08:00");
527
+ });
528
+ });
529
+
530
+ /*
531
+ * ===========================================================================
532
+ * END-TO-END getEvents: coverage oracle (single user, UTC schedule)
533
+ * ===========================================================================
534
+ */
535
+
536
+ describe("getEvents coverage matches the wall-clock oracle (UTC schedule, single user)", () => {
537
+ interface OracleCase {
538
+ name: string;
539
+ windows: Array<WeeklyResctriction>;
540
+ }
541
+
542
+ const cases: Array<OracleCase> = [
543
+ {
544
+ name: "non-wrap Mon-Fri (Mon 00:00 -> Sat 00:00)",
545
+ windows: [win("2025-01-06 00:00", "2025-01-11 00:00", UTC)],
546
+ },
547
+ {
548
+ name: "wrap weekend (Fri 20:00 -> Mon 08:00)",
549
+ windows: [win("2025-01-03 20:00", "2025-01-06 08:00", UTC)],
550
+ },
551
+ {
552
+ name: "two multi-day weekday windows (Mon 09:00->Wed 17:00 & Fri 09:00->Sat 12:00)",
553
+ windows: [
554
+ win("2025-01-06 09:00", "2025-01-08 17:00", UTC), // Mon 09:00 -> Wed 17:00
555
+ win("2025-01-10 09:00", "2025-01-11 12:00", UTC), // Fri 09:00 -> Sat 12:00
556
+ ],
557
+ },
558
+ {
559
+ name: "two overlapping windows collapse (Mon 00:00->Wed 12:00 & Wed 10:00->Fri 00:00)",
560
+ windows: [
561
+ win("2025-01-06 00:00", "2025-01-08 12:00", UTC),
562
+ win("2025-01-08 10:00", "2025-01-10 00:00", UTC),
563
+ ],
564
+ },
565
+ {
566
+ name: "near-whole-week wrap (Mon 00:01 -> Mon 00:00) covers every day",
567
+ windows: [win("2025-01-06 00:01", "2025-01-13 00:00", UTC)],
568
+ },
569
+ {
570
+ name: "wrap weekend + a disjoint Wed daytime window (mixed wrap & non-wrap)",
571
+ windows: [
572
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC), // Fri 20:00 -> Mon 08:00
573
+ win("2025-01-08 09:00", "2025-01-08 17:00", UTC), // Wed 09:00 -> Wed 17:00
574
+ ],
575
+ },
576
+ ];
577
+
578
+ for (const c of cases) {
579
+ test(`${c.name}`, () => {
580
+ const layer: LayerProps = weeklyLayer({
581
+ users: ["solo"],
582
+ zone: UTC,
583
+ windows: c.windows,
584
+ layerStart: UTC_SUN_JAN5,
585
+ handOffTime: at("2025-02-09 00:00", UTC), // > 1 month => single period
586
+ rotationType: EventInterval.Month,
587
+ rotationCount: 1,
588
+ });
589
+ const calendarEnd: Date = at("2025-02-02 00:00", UTC);
590
+ const events: Array<CalendarEvent> = expand(
591
+ layer,
592
+ UTC_SUN_JAN5,
593
+ calendarEnd,
594
+ );
595
+
596
+ // Single user => coverage must be strictly non-overlapping & de-duplicated.
597
+ expect(overlapProblems(events)).toEqual([]);
598
+ // Every event is the single user.
599
+ for (const e of events) {
600
+ expect(e.title).toBe("solo");
601
+ }
602
+
603
+ const result: { samples: number; mismatches: Array<string> } =
604
+ coverageMismatches({
605
+ events,
606
+ windows: c.windows,
607
+ zone: UTC,
608
+ start: UTC_SUN_JAN5,
609
+ end: calendarEnd,
610
+ stepSeconds: 41 * 60,
611
+ guardSeconds: 120,
612
+ });
613
+ expect(result.samples).toBeGreaterThan(500);
614
+ expect(result.mismatches).toEqual([]);
615
+ });
616
+ }
617
+
618
+ test("near-whole-week window actually covers a sample on all 7 weekdays", () => {
619
+ const windows: Array<WeeklyResctriction> = [
620
+ win("2025-01-06 00:01", "2025-01-13 00:00", UTC),
621
+ ];
622
+ const layer: LayerProps = weeklyLayer({
623
+ users: ["solo"],
624
+ zone: UTC,
625
+ windows,
626
+ layerStart: UTC_SUN_JAN5,
627
+ handOffTime: at("2025-02-09 00:00", UTC),
628
+ rotationType: EventInterval.Month,
629
+ rotationCount: 1,
630
+ });
631
+ const events: Array<CalendarEvent> = expand(
632
+ layer,
633
+ UTC_SUN_JAN5,
634
+ at("2025-02-02 00:00", UTC),
635
+ );
636
+ // Sun..Sat of the second week (all inside range), sampled at noon.
637
+ const days: Array<string> = [
638
+ "2025-01-12",
639
+ "2025-01-13",
640
+ "2025-01-14",
641
+ "2025-01-15",
642
+ "2025-01-16",
643
+ "2025-01-17",
644
+ "2025-01-18",
645
+ ];
646
+ for (const d of days) {
647
+ expect(coveredByEvents(events, at(`${d} 12:00`, UTC))).toBe(true);
648
+ }
649
+ });
650
+ });
651
+
652
+ /*
653
+ * ===========================================================================
654
+ * END-TO-END getEvents: concrete wrap boundary (UTC)
655
+ * ===========================================================================
656
+ */
657
+
658
+ describe("getEvents: wrap-around weekend boundaries are exact (UTC schedule)", () => {
659
+ test("a Saturday instant resolves to a Fri 20:00 -> Mon 08:00 block", () => {
660
+ const windows: Array<WeeklyResctriction> = [
661
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC),
662
+ ];
663
+ const layer: LayerProps = weeklyLayer({
664
+ users: ["solo"],
665
+ zone: UTC,
666
+ windows,
667
+ layerStart: UTC_SUN_JAN5,
668
+ handOffTime: at("2025-02-09 00:00", UTC),
669
+ rotationType: EventInterval.Month,
670
+ rotationCount: 1,
671
+ });
672
+ const events: Array<CalendarEvent> = expand(
673
+ layer,
674
+ UTC_SUN_JAN5,
675
+ at("2025-02-02 00:00", UTC),
676
+ );
677
+
678
+ const cover: CalendarEvent | undefined = coveringEvent(
679
+ events,
680
+ at("2025-01-18 12:00", UTC), // Saturday of the 3rd week
681
+ );
682
+ expect(cover).toBeTruthy();
683
+ expect(moment.tz(cover!.start, UTC).format("YYYY-MM-DD HH:mm")).toBe(
684
+ "2025-01-17 20:00",
685
+ );
686
+ expect(moment.tz(cover!.end, UTC).format("YYYY-MM-DD HH:mm")).toBe(
687
+ "2025-01-20 08:00",
688
+ );
689
+
690
+ // Fri 19:30 is NOT covered; Fri 20:30 IS; Mon 08:30 is NOT.
691
+ expect(coveredByEvents(events, at("2025-01-17 19:30", UTC))).toBe(false);
692
+ expect(coveredByEvents(events, at("2025-01-17 20:30", UTC))).toBe(true);
693
+ expect(coveredByEvents(events, at("2025-01-20 08:30", UTC))).toBe(false);
694
+ });
695
+ });
696
+
697
+ /*
698
+ * ===========================================================================
699
+ * F2 (weekly): resolving on the weekend gap returns the correct next-week user
700
+ * ===========================================================================
701
+ */
702
+
703
+ describe("F2 weekly: weekend-gap query resolves the calendar's next-week user", () => {
704
+ test("weekly rotation [A,B,C] + Mon-Fri restriction: every weekend gap -> next week's user (UTC)", () => {
705
+ const layerStart: Date = at("2025-01-06 00:00", UTC); // Monday
706
+ const layer: LayerProps = weeklyLayer({
707
+ users: ["A", "B", "C"],
708
+ zone: UTC,
709
+ windows: [win("2025-01-06 00:00", "2025-01-11 00:00", UTC)], // Mon 00:00 -> Sat 00:00
710
+ layerStart,
711
+ handOffTime: Recurring.getNextDateInterval(
712
+ layerStart,
713
+ (() => {
714
+ const r: Recurring = new Recurring();
715
+ r.intervalType = EventInterval.Week;
716
+ r.intervalCount = new PositiveNumber(1);
717
+ return r;
718
+ })(),
719
+ ),
720
+ rotationType: EventInterval.Week,
721
+ rotationCount: 1,
722
+ });
723
+
724
+ const full: Array<CalendarEvent> = expand(
725
+ layer,
726
+ layerStart,
727
+ at("2025-02-24 00:00", UTC),
728
+ );
729
+
730
+ // Rotation stays continuous across the dead weekends: A,B,C,A,B,...
731
+ const weekMondays: Array<string> = [
732
+ "2025-01-06",
733
+ "2025-01-13",
734
+ "2025-01-20",
735
+ "2025-01-27",
736
+ "2025-02-03",
737
+ ];
738
+ const expectedUsers: Array<string> = ["A", "B", "C", "A", "B"];
739
+ weekMondays.forEach((mon: string, idx: number) => {
740
+ const cover: CalendarEvent | undefined = coveringEvent(
741
+ full,
742
+ at(`${mon} 12:00`, UTC),
743
+ );
744
+ expect(cover?.title).toBe(expectedUsers[idx]);
745
+ });
746
+
747
+ /*
748
+ * A weekend query (Saturday or Sunday) must resolve the NEXT week's user,
749
+ * matching what the full calendar assigns — never carrying THIS week's user.
750
+ */
751
+ const weekendInstants: Array<string> = [
752
+ "2025-01-11 12:00", // Sat, week0 gap
753
+ "2025-01-12 09:00", // Sun, week0 gap
754
+ "2025-01-18 20:00", // Sat, week1 gap
755
+ "2025-01-25 06:00", // Sat, week2 gap
756
+ "2025-02-01 15:00", // Sat, week3 gap
757
+ ];
758
+ for (const iso of weekendInstants) {
759
+ const askAt: Date = at(iso, UTC);
760
+ const nextCovered: CalendarEvent | undefined = full.find(
761
+ (e: CalendarEvent) => {
762
+ return OneUptimeDate.isAfter(e.start, askAt);
763
+ },
764
+ );
765
+ const windowed: Array<CalendarEvent> = new LayerUtil().getEvents(
766
+ {
767
+ ...layer,
768
+ calendarStartDate: askAt,
769
+ calendarEndDate: OneUptimeDate.addRemoveDays(askAt, 21),
770
+ },
771
+ { getNumberOfEvents: 1 },
772
+ );
773
+ expect(nextCovered).toBeTruthy();
774
+ expect(windowed[0]?.title).toBe(nextCovered?.title);
775
+ }
776
+ });
777
+
778
+ test("weekly rotation [A,B] + Mon-Fri restriction across the March DST week (NY)", () => {
779
+ const layerStart: Date = at("2026-03-02 00:00", NY); // Monday
780
+ const layer: LayerProps = weeklyLayer({
781
+ users: ["A", "B"],
782
+ zone: NY,
783
+ windows: [win("2026-03-02 00:00", "2026-03-07 00:00", NY)], // Mon 00:00 -> Sat 00:00 NY
784
+ layerStart,
785
+ handOffTime: at("2026-03-09 00:00", NY), // first handoff Monday 00:00 NY
786
+ rotationType: EventInterval.Week,
787
+ rotationCount: 1,
788
+ });
789
+
790
+ const full: Array<CalendarEvent> = expand(
791
+ layer,
792
+ layerStart,
793
+ at("2026-04-06 00:00", NY),
794
+ );
795
+
796
+ // Week0 (Mar2-6) = A, week1 (Mar9-13, post-DST) = B, week2 (Mar16-20) = A.
797
+ expect(coveringEvent(full, at("2026-03-04 12:00", NY))?.title).toBe("A");
798
+ expect(coveringEvent(full, at("2026-03-11 12:00", NY))?.title).toBe("B");
799
+ expect(coveringEvent(full, at("2026-03-18 12:00", NY))?.title).toBe("A");
800
+
801
+ // Weekend gap over the spring-forward Sunday (Mar 8) resolves next week = B.
802
+ const askAt: Date = at("2026-03-07 12:00", NY); // Saturday
803
+ const nextCovered: CalendarEvent | undefined = full.find(
804
+ (e: CalendarEvent) => {
805
+ return OneUptimeDate.isAfter(e.start, askAt);
806
+ },
807
+ );
808
+ const windowed: Array<CalendarEvent> = new LayerUtil().getEvents(
809
+ {
810
+ ...layer,
811
+ calendarStartDate: askAt,
812
+ calendarEndDate: OneUptimeDate.addRemoveDays(askAt, 21),
813
+ },
814
+ { getNumberOfEvents: 1 },
815
+ );
816
+ expect(nextCovered?.title).toBe("B");
817
+ expect(windowed[0]?.title).toBe(nextCovered?.title);
818
+ });
819
+ });
820
+
821
+ /*
822
+ * ===========================================================================
823
+ * DST (America/New_York): wall-clock boundaries do not drift
824
+ * ===========================================================================
825
+ */
826
+
827
+ describe("DST (NY): weekend wrap coverage & boundaries hold wall-clock across transitions", () => {
828
+ // Single-user monthly rotation, Fri 20:00 -> Mon 08:00 NY, over an entire month.
829
+ function monthlyWrapEvents(
830
+ layerStartISO: string,
831
+ handOffISO: string,
832
+ calendarEndISO: string,
833
+ ): { events: Array<CalendarEvent>; windows: Array<WeeklyResctriction> } {
834
+ const windows: Array<WeeklyResctriction> = [
835
+ win("2026-01-02 20:00", "2026-01-05 08:00", NY), // a Fri 20:00 -> Mon 08:00 NY
836
+ ];
837
+ const layer: LayerProps = weeklyLayer({
838
+ users: ["solo"],
839
+ zone: NY,
840
+ windows,
841
+ layerStart: at(layerStartISO, NY),
842
+ handOffTime: at(handOffISO, NY),
843
+ rotationType: EventInterval.Month,
844
+ rotationCount: 1,
845
+ });
846
+ return {
847
+ events: expand(layer, at(layerStartISO, NY), at(calendarEndISO, NY)),
848
+ windows,
849
+ };
850
+ }
851
+
852
+ test("spring-forward month (March 2026): every weekend closes Mon 08:00 NY (no drift) & oracle matches", () => {
853
+ const built: {
854
+ events: Array<CalendarEvent>;
855
+ windows: Array<WeeklyResctriction>;
856
+ } = monthlyWrapEvents(
857
+ "2026-03-01 00:00",
858
+ "2026-04-05 00:00",
859
+ "2026-04-01 00:00",
860
+ );
861
+ const events: Array<CalendarEvent> = built.events;
862
+
863
+ expect(overlapProblems(events)).toEqual([]);
864
+
865
+ /*
866
+ * Weekend spanning the spring-forward (Sun Mar 8): open Fri Mar 6 20:00,
867
+ * close Mon Mar 9 08:00 NY. Wall-clock must not drift by the DST hour.
868
+ */
869
+ expect(coveredByEvents(events, at("2026-03-06 20:30", NY))).toBe(true);
870
+ expect(coveredByEvents(events, at("2026-03-08 12:00", NY))).toBe(true); // DST Sunday
871
+ expect(coveredByEvents(events, at("2026-03-09 07:30", NY))).toBe(true);
872
+ expect(coveredByEvents(events, at("2026-03-09 08:30", NY))).toBe(false);
873
+
874
+ /*
875
+ * The Monday close stays exactly 08:00 NY for EVERY weekend of the month
876
+ * (locks that the DST drift never propagates forward — audit F8).
877
+ */
878
+ const mondayCloses: Array<string> = [
879
+ "2026-03-09",
880
+ "2026-03-16",
881
+ "2026-03-23",
882
+ "2026-03-30",
883
+ ];
884
+ for (const mon of mondayCloses) {
885
+ expect(coveredByEvents(events, at(`${mon} 07:30`, NY))).toBe(true);
886
+ expect(coveredByEvents(events, at(`${mon} 08:30`, NY))).toBe(false);
887
+ }
888
+ // Friday opens stay exactly 20:00 NY.
889
+ const fridayOpens: Array<string> = [
890
+ "2026-03-06",
891
+ "2026-03-13",
892
+ "2026-03-20",
893
+ "2026-03-27",
894
+ ];
895
+ for (const fri of fridayOpens) {
896
+ expect(coveredByEvents(events, at(`${fri} 19:30`, NY))).toBe(false);
897
+ expect(coveredByEvents(events, at(`${fri} 20:30`, NY))).toBe(true);
898
+ }
899
+
900
+ const result: { samples: number; mismatches: Array<string> } =
901
+ coverageMismatches({
902
+ events,
903
+ windows: built.windows,
904
+ zone: NY,
905
+ start: at("2026-03-01 00:00", NY),
906
+ end: at("2026-04-01 00:00", NY),
907
+ stepSeconds: 41 * 60,
908
+ guardSeconds: 180,
909
+ });
910
+ expect(result.samples).toBeGreaterThan(500);
911
+ expect(result.mismatches).toEqual([]);
912
+ });
913
+
914
+ test("fall-back month (November 2026): weekend Monday close holds 08:00 NY & oracle matches", () => {
915
+ const built: {
916
+ events: Array<CalendarEvent>;
917
+ windows: Array<WeeklyResctriction>;
918
+ } = monthlyWrapEvents(
919
+ "2026-11-01 00:00",
920
+ "2026-12-06 00:00",
921
+ "2026-12-01 00:00",
922
+ );
923
+ const events: Array<CalendarEvent> = built.events;
924
+
925
+ expect(overlapProblems(events)).toEqual([]);
926
+
927
+ /*
928
+ * Fall-back Sunday is Nov 1 2026 (25-hour day). Layer starts Sun Nov 1 00:00;
929
+ * that Sunday is inside the (previous Friday's) weekend window -> covered,
930
+ * and the Monday close is Nov 2 08:00 NY.
931
+ */
932
+ expect(coveredByEvents(events, at("2026-11-01 12:00", NY))).toBe(true);
933
+ expect(coveredByEvents(events, at("2026-11-02 07:30", NY))).toBe(true);
934
+ expect(coveredByEvents(events, at("2026-11-02 08:30", NY))).toBe(false);
935
+
936
+ // Subsequent weekends unaffected by the fall-back: close stays 08:00 NY.
937
+ const mondayCloses: Array<string> = [
938
+ "2026-11-09",
939
+ "2026-11-16",
940
+ "2026-11-23",
941
+ ];
942
+ for (const mon of mondayCloses) {
943
+ expect(coveredByEvents(events, at(`${mon} 07:30`, NY))).toBe(true);
944
+ expect(coveredByEvents(events, at(`${mon} 08:30`, NY))).toBe(false);
945
+ }
946
+
947
+ const result: { samples: number; mismatches: Array<string> } =
948
+ coverageMismatches({
949
+ events,
950
+ windows: built.windows,
951
+ zone: NY,
952
+ start: at("2026-11-01 00:00", NY),
953
+ end: at("2026-12-01 00:00", NY),
954
+ stepSeconds: 41 * 60,
955
+ guardSeconds: 180,
956
+ });
957
+ expect(result.samples).toBeGreaterThan(500);
958
+ expect(result.mismatches).toEqual([]);
959
+ });
960
+
961
+ test("non-wrap Mon-Fri window keeps its Saturday-00:00 close wall-clock across the DST week (NY)", () => {
962
+ const windows: Array<WeeklyResctriction> = [
963
+ win("2026-03-02 00:00", "2026-03-07 00:00", NY), // Mon 00:00 -> Sat 00:00 NY
964
+ ];
965
+ const layer: LayerProps = weeklyLayer({
966
+ users: ["solo"],
967
+ zone: NY,
968
+ windows,
969
+ layerStart: at("2026-03-01 00:00", NY), // Sunday
970
+ handOffTime: at("2026-04-05 00:00", NY),
971
+ rotationType: EventInterval.Month,
972
+ rotationCount: 1,
973
+ });
974
+ const events: Array<CalendarEvent> = expand(
975
+ layer,
976
+ at("2026-03-01 00:00", NY),
977
+ at("2026-04-01 00:00", NY),
978
+ );
979
+
980
+ expect(overlapProblems(events)).toEqual([]);
981
+
982
+ /*
983
+ * The DST-week Wednesday is covered; the weekend is not; the Saturday 00:00
984
+ * boundary does not drift by the spring-forward hour.
985
+ */
986
+ expect(coveredByEvents(events, at("2026-03-11 12:00", NY))).toBe(true); // Wed (DST week)
987
+ expect(coveredByEvents(events, at("2026-03-13 23:30", NY))).toBe(true); // Fri night, before Sat 00:00
988
+ expect(coveredByEvents(events, at("2026-03-14 00:30", NY))).toBe(false); // Sat, after close
989
+ expect(coveredByEvents(events, at("2026-03-14 12:00", NY))).toBe(false); // Sat
990
+ expect(coveredByEvents(events, at("2026-03-15 12:00", NY))).toBe(false); // Sun
991
+
992
+ const result: { samples: number; mismatches: Array<string> } =
993
+ coverageMismatches({
994
+ events,
995
+ windows,
996
+ zone: NY,
997
+ start: at("2026-03-01 00:00", NY),
998
+ end: at("2026-04-01 00:00", NY),
999
+ stepSeconds: 41 * 60,
1000
+ guardSeconds: 180,
1001
+ });
1002
+ expect(result.samples).toBeGreaterThan(500);
1003
+ expect(result.mismatches).toEqual([]);
1004
+ });
1005
+ });
1006
+
1007
+ /*
1008
+ * ===========================================================================
1009
+ * Multi-week tiling via a 4-week (weekly x4) rotation period, coverage oracle
1010
+ * ===========================================================================
1011
+ */
1012
+
1013
+ describe("multi-ISO-week rotation period is tiled with no duplicate/overlap (weekly x4, UTC)", () => {
1014
+ test("wrap weekend over a 4-week single period matches oracle", () => {
1015
+ const windows: Array<WeeklyResctriction> = [
1016
+ win("2025-01-03 20:00", "2025-01-06 08:00", UTC),
1017
+ ];
1018
+ const rot: Recurring = new Recurring();
1019
+ rot.intervalType = EventInterval.Week;
1020
+ rot.intervalCount = new PositiveNumber(4);
1021
+ const layer: LayerProps = {
1022
+ users: [user("solo")],
1023
+ startDateTimeOfLayer: UTC_SUN_JAN5,
1024
+ restrictionTimes: weeklyRestriction(windows),
1025
+ handOffTime: Recurring.getNextDateInterval(UTC_SUN_JAN5, rot), // +28 days
1026
+ rotation: rot,
1027
+ timezone: UTC,
1028
+ };
1029
+ const calendarEnd: Date = at("2025-01-31 00:00", UTC); // inside the first 4-week period
1030
+ const events: Array<CalendarEvent> = expand(
1031
+ layer,
1032
+ UTC_SUN_JAN5,
1033
+ calendarEnd,
1034
+ );
1035
+
1036
+ expect(overlapProblems(events)).toEqual([]);
1037
+ for (const e of events) {
1038
+ expect(e.title).toBe("solo");
1039
+ }
1040
+
1041
+ const result: { samples: number; mismatches: Array<string> } =
1042
+ coverageMismatches({
1043
+ events,
1044
+ windows,
1045
+ zone: UTC,
1046
+ start: UTC_SUN_JAN5,
1047
+ end: calendarEnd,
1048
+ stepSeconds: 37 * 60,
1049
+ guardSeconds: 120,
1050
+ });
1051
+ expect(result.samples).toBeGreaterThan(500);
1052
+ expect(result.mismatches).toEqual([]);
1053
+ });
1054
+ });
1055
+
1056
+ /*
1057
+ * ===========================================================================
1058
+ * Weekly restriction with an EMPTY window array behaves as unrestricted
1059
+ * ===========================================================================
1060
+ */
1061
+
1062
+ describe("Weekly restriction type with empty windows => full (unrestricted) coverage", () => {
1063
+ test("getEvents returns continuous coverage for RestrictionType.Weekly with []", () => {
1064
+ const layer: LayerProps = weeklyLayer({
1065
+ users: ["solo"],
1066
+ zone: UTC,
1067
+ windows: [],
1068
+ layerStart: UTC_SUN_JAN5,
1069
+ handOffTime: at("2025-02-09 00:00", UTC),
1070
+ rotationType: EventInterval.Month,
1071
+ rotationCount: 1,
1072
+ });
1073
+ const calendarEnd: Date = at("2025-01-12 00:00", UTC);
1074
+ const events: Array<CalendarEvent> = expand(
1075
+ layer,
1076
+ UTC_SUN_JAN5,
1077
+ calendarEnd,
1078
+ );
1079
+
1080
+ // No gaps: sample the whole window, every instant covered.
1081
+ let cursorMs: number = UTC_SUN_JAN5.getTime() + 613 * 1000;
1082
+ let samples: number = 0;
1083
+ const gaps: Array<string> = [];
1084
+ while (cursorMs < calendarEnd.getTime()) {
1085
+ const t: Date = new Date(cursorMs);
1086
+ if (!coveredByEvents(events, t)) {
1087
+ gaps.push(t.toISOString());
1088
+ }
1089
+ samples++;
1090
+ cursorMs += 37 * 60 * 1000;
1091
+ }
1092
+ expect(samples).toBeGreaterThan(50);
1093
+ expect(gaps).toEqual([]);
1094
+ });
1095
+ });