@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,1126 @@
1
+ /**
2
+ * EXHAUSTIVE tests for UserOverrideUtil:
3
+ * - isOverrideApplicable (global vs policy-scoped vs no-context)
4
+ * - splitEventByOverride (via applyOverridesToEvents)
5
+ * - applyOverridesToEvents (ordering, precedence, id reassignment, meta)
6
+ *
7
+ * UserOverrideUtil is pure timestamp math (no timezone conversion happens inside
8
+ * it), so the resolved segments are invariant to the process TZ. Date
9
+ * comparisons are done at SECOND precision (OneUptimeDate.isAfter/isBefore/
10
+ * isOnOrAfter/isOnOrBefore all truncate to whole seconds), which every boundary
11
+ * test below pins down explicitly.
12
+ *
13
+ * These lock in the CORRECT (already-fixed) behavior:
14
+ * - non-transitive overrides (a substituted segment is frozen via meta),
15
+ * - consistent second-precision boundary handling (no inverted/zero-length
16
+ * segments from sub-second offsets),
17
+ * - policy-scoped overrides win over globals for the same user/window,
18
+ * - policy-scoped overrides never leak into a different policy.
19
+ */
20
+ import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
21
+ import UserOverrideUtil, {
22
+ UserOverrideRecord,
23
+ OverrideEventMeta,
24
+ OVERRIDE_META_KEY,
25
+ } from "../../../Types/OnCallDutyPolicy/UserOverrideUtil";
26
+
27
+ /*
28
+ * All events/overrides live on a single arbitrary calendar day. Because the
29
+ * util only compares raw timestamps, the choice of day/zone is irrelevant.
30
+ */
31
+ function at(
32
+ hours: number,
33
+ minutes: number = 0,
34
+ seconds: number = 0,
35
+ ms: number = 0,
36
+ ): Date {
37
+ return new Date(2026, 0, 1, hours, minutes, seconds, ms);
38
+ }
39
+
40
+ let nextId: number = 1;
41
+ function event(
42
+ title: string,
43
+ start: Date,
44
+ end: Date,
45
+ extra?: Partial<CalendarEvent>,
46
+ ): CalendarEvent {
47
+ return {
48
+ id: nextId++,
49
+ title: title,
50
+ allDay: false,
51
+ start: start,
52
+ end: end,
53
+ ...extra,
54
+ };
55
+ }
56
+
57
+ function hourEvent(
58
+ title: string,
59
+ startHour: number,
60
+ endHour: number,
61
+ extra?: Partial<CalendarEvent>,
62
+ ): CalendarEvent {
63
+ return event(title, at(startHour), at(endHour), extra);
64
+ }
65
+
66
+ function override(data: {
67
+ from: string;
68
+ to: string;
69
+ startsAt: Date;
70
+ endsAt: Date;
71
+ policyId?: string | null | undefined;
72
+ }): UserOverrideRecord {
73
+ return {
74
+ overrideUserId: data.from,
75
+ routeAlertsToUserId: data.to,
76
+ startsAt: data.startsAt,
77
+ endsAt: data.endsAt,
78
+ onCallDutyPolicyId: data.policyId,
79
+ };
80
+ }
81
+
82
+ function hourOverride(
83
+ from: string,
84
+ to: string,
85
+ startHour: number,
86
+ endHour: number,
87
+ policyId?: string | null | undefined,
88
+ ): UserOverrideRecord {
89
+ return override({
90
+ from: from,
91
+ to: to,
92
+ startsAt: at(startHour),
93
+ endsAt: at(endHour),
94
+ policyId: policyId,
95
+ });
96
+ }
97
+
98
+ function resolvedAt(events: Array<CalendarEvent>, t: Date): string | null {
99
+ for (const e of events) {
100
+ if (t.getTime() >= e.start.getTime() && t.getTime() < e.end.getTime()) {
101
+ return e.title;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ interface Seg {
108
+ title: string;
109
+ start: number;
110
+ end: number;
111
+ meta: boolean;
112
+ }
113
+
114
+ function segs(events: Array<CalendarEvent>): Array<Seg> {
115
+ return events.map((e: CalendarEvent) => {
116
+ return {
117
+ title: e.title,
118
+ start: e.start.getTime(),
119
+ end: e.end.getTime(),
120
+ meta: UserOverrideUtil.getOverrideMeta(e) !== null,
121
+ };
122
+ });
123
+ }
124
+
125
+ // Expected segment in whole-hour shorthand.
126
+ type ExpectedHourSeg = [
127
+ title: string,
128
+ startHour: number,
129
+ endHour: number,
130
+ meta: boolean,
131
+ ];
132
+
133
+ function expectedHourSegs(expected: Array<ExpectedHourSeg>): Array<Seg> {
134
+ return expected.map((e: ExpectedHourSeg) => {
135
+ return {
136
+ title: e[0],
137
+ start: at(e[1]).getTime(),
138
+ end: at(e[2]).getTime(),
139
+ meta: e[3],
140
+ };
141
+ });
142
+ }
143
+
144
+ /*
145
+ * ---------------------------------------------------------------------------
146
+ * isOverrideApplicable
147
+ * ---------------------------------------------------------------------------
148
+ */
149
+
150
+ describe("isOverrideApplicable: global vs policy-scoped vs no-context", () => {
151
+ const window: { startsAt: Date; endsAt: Date } = {
152
+ startsAt: at(9),
153
+ endsAt: at(11),
154
+ };
155
+
156
+ test("global override (policyId null) applies regardless of context", () => {
157
+ const ov: UserOverrideRecord = override({
158
+ from: "A",
159
+ to: "B",
160
+ ...window,
161
+ policyId: null,
162
+ });
163
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "policy-1")).toBe(true);
164
+ expect(UserOverrideUtil.isOverrideApplicable(ov, undefined)).toBe(true);
165
+ expect(UserOverrideUtil.isOverrideApplicable(ov, null)).toBe(true);
166
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "")).toBe(true);
167
+ });
168
+
169
+ test("global override (policyId undefined / omitted) applies regardless of context", () => {
170
+ const ov: UserOverrideRecord = {
171
+ overrideUserId: "A",
172
+ routeAlertsToUserId: "B",
173
+ startsAt: window.startsAt,
174
+ endsAt: window.endsAt,
175
+ // onCallDutyPolicyId omitted entirely -> undefined -> global
176
+ };
177
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "policy-1")).toBe(true);
178
+ expect(UserOverrideUtil.isOverrideApplicable(ov, undefined)).toBe(true);
179
+ });
180
+
181
+ test("policy-scoped override applies ONLY to the matching policy", () => {
182
+ const ov: UserOverrideRecord = override({
183
+ from: "A",
184
+ to: "B",
185
+ ...window,
186
+ policyId: "policy-1",
187
+ });
188
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "policy-1")).toBe(true);
189
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "policy-2")).toBe(false);
190
+ });
191
+
192
+ test("policy-scoped override does NOT apply when caller has no policy context", () => {
193
+ const ov: UserOverrideRecord = override({
194
+ from: "A",
195
+ to: "B",
196
+ ...window,
197
+ policyId: "policy-1",
198
+ });
199
+ expect(UserOverrideUtil.isOverrideApplicable(ov, undefined)).toBe(false);
200
+ expect(UserOverrideUtil.isOverrideApplicable(ov, null)).toBe(false);
201
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "")).toBe(false);
202
+ });
203
+
204
+ test("empty-string policyId on the override is treated as global (falsy)", () => {
205
+ /*
206
+ * NOTE: current behavior - an empty-string onCallDutyPolicyId is falsy and
207
+ * therefore treated as a global override.
208
+ */
209
+ const ov: UserOverrideRecord = override({
210
+ from: "A",
211
+ to: "B",
212
+ ...window,
213
+ policyId: "",
214
+ });
215
+ expect(UserOverrideUtil.isOverrideApplicable(ov, undefined)).toBe(true);
216
+ expect(UserOverrideUtil.isOverrideApplicable(ov, "policy-1")).toBe(true);
217
+ });
218
+ });
219
+
220
+ /*
221
+ * ---------------------------------------------------------------------------
222
+ * Positional matrix: one global override A->B against event A[8,14]
223
+ * ---------------------------------------------------------------------------
224
+ */
225
+
226
+ describe("splitEventByOverride positional matrix (event A[8,14], override A->B)", () => {
227
+ interface PosCase {
228
+ name: string;
229
+ startHour: number;
230
+ endHour: number;
231
+ expected: Array<ExpectedHourSeg>;
232
+ }
233
+
234
+ const cases: Array<PosCase> = [
235
+ {
236
+ name: "fully before the event -> untouched",
237
+ startHour: 4,
238
+ endHour: 6,
239
+ expected: [["A", 8, 14, false]],
240
+ },
241
+ {
242
+ name: "endsAt exactly abuts event.start (second-precision) -> untouched",
243
+ startHour: 6,
244
+ endHour: 8,
245
+ expected: [["A", 8, 14, false]],
246
+ },
247
+ {
248
+ name: "overlaps the left edge -> B[8,10] | A[10,14]",
249
+ startHour: 6,
250
+ endHour: 10,
251
+ expected: [
252
+ ["B", 8, 10, true],
253
+ ["A", 10, 14, false],
254
+ ],
255
+ },
256
+ {
257
+ name: "starts at event.start, ends inside -> B[8,10] | A[10,14]",
258
+ startHour: 8,
259
+ endHour: 10,
260
+ expected: [
261
+ ["B", 8, 10, true],
262
+ ["A", 10, 14, false],
263
+ ],
264
+ },
265
+ {
266
+ name: "strictly inside -> A[8,10] | B[10,12] | A[12,14]",
267
+ startHour: 10,
268
+ endHour: 12,
269
+ expected: [
270
+ ["A", 8, 10, false],
271
+ ["B", 10, 12, true],
272
+ ["A", 12, 14, false],
273
+ ],
274
+ },
275
+ {
276
+ name: "starts inside, ends at event.end -> A[8,10] | B[10,14]",
277
+ startHour: 10,
278
+ endHour: 14,
279
+ expected: [
280
+ ["A", 8, 10, false],
281
+ ["B", 10, 14, true],
282
+ ],
283
+ },
284
+ {
285
+ name: "overlaps the right edge -> A[8,10] | B[10,14]",
286
+ startHour: 10,
287
+ endHour: 16,
288
+ expected: [
289
+ ["A", 8, 10, false],
290
+ ["B", 10, 14, true],
291
+ ],
292
+ },
293
+ {
294
+ name: "exactly equals the event window -> B[8,14]",
295
+ startHour: 8,
296
+ endHour: 14,
297
+ expected: [["B", 8, 14, true]],
298
+ },
299
+ {
300
+ name: "fully covers the event (clamped to event window) -> B[8,14]",
301
+ startHour: 6,
302
+ endHour: 16,
303
+ expected: [["B", 8, 14, true]],
304
+ },
305
+ {
306
+ name: "startsAt exactly abuts event.end (second-precision) -> untouched",
307
+ startHour: 14,
308
+ endHour: 16,
309
+ expected: [["A", 8, 14, false]],
310
+ },
311
+ {
312
+ name: "fully after the event -> untouched",
313
+ startHour: 16,
314
+ endHour: 18,
315
+ expected: [["A", 8, 14, false]],
316
+ },
317
+ ];
318
+
319
+ for (const c of cases) {
320
+ test(c.name, () => {
321
+ const base: CalendarEvent = hourEvent("A", 8, 14);
322
+ const result: Array<CalendarEvent> =
323
+ UserOverrideUtil.applyOverridesToEvents({
324
+ events: [base],
325
+ overrides: [hourOverride("A", "B", c.startHour, c.endHour)],
326
+ });
327
+ expect(segs(result)).toEqual(expectedHourSegs(c.expected));
328
+ // ids are always reassigned to a 1..n sequence when overrides are applied.
329
+ expect(
330
+ result.map((e: CalendarEvent) => {
331
+ return e.id;
332
+ }),
333
+ ).toEqual(
334
+ result.map((_: CalendarEvent, i: number) => {
335
+ return i + 1;
336
+ }),
337
+ );
338
+ // no segment ever has a non-positive duration.
339
+ for (const e of result) {
340
+ expect(e.end.getTime()).toBeGreaterThan(e.start.getTime());
341
+ }
342
+ });
343
+ }
344
+ });
345
+
346
+ /*
347
+ * ---------------------------------------------------------------------------
348
+ * Second-precision boundary behavior (sub-second offsets)
349
+ * ---------------------------------------------------------------------------
350
+ */
351
+
352
+ describe("second-precision boundaries (event A 10:00:00.000 - 12:00:00.000)", () => {
353
+ function baseEvent(): CalendarEvent {
354
+ return event("A", at(10, 0, 0, 0), at(12, 0, 0, 0));
355
+ }
356
+
357
+ test("override starting exactly at event end (same ms) -> no substitution", () => {
358
+ const result: Array<CalendarEvent> =
359
+ UserOverrideUtil.applyOverridesToEvents({
360
+ events: [baseEvent()],
361
+ overrides: [
362
+ override({
363
+ from: "A",
364
+ to: "B",
365
+ startsAt: at(12, 0, 0, 0),
366
+ endsAt: at(13),
367
+ }),
368
+ ],
369
+ });
370
+ expect(segs(result)).toEqual([
371
+ {
372
+ title: "A",
373
+ start: at(10).getTime(),
374
+ end: at(12).getTime(),
375
+ meta: false,
376
+ },
377
+ ]);
378
+ });
379
+
380
+ test("override starting mid-second after event end -> still no substitution", () => {
381
+ // 12:00:00.900 truncates to 12:00:00 == event.end second -> isOnOrAfter true.
382
+ const result: Array<CalendarEvent> =
383
+ UserOverrideUtil.applyOverridesToEvents({
384
+ events: [baseEvent()],
385
+ overrides: [
386
+ override({
387
+ from: "A",
388
+ to: "B",
389
+ startsAt: at(12, 0, 0, 900),
390
+ endsAt: at(13),
391
+ }),
392
+ ],
393
+ });
394
+ expect(result).toHaveLength(1);
395
+ expect(result[0]!.title).toBe("A");
396
+ });
397
+
398
+ test("override ending exactly at event start -> no substitution", () => {
399
+ const result: Array<CalendarEvent> =
400
+ UserOverrideUtil.applyOverridesToEvents({
401
+ events: [baseEvent()],
402
+ overrides: [
403
+ override({
404
+ from: "A",
405
+ to: "B",
406
+ startsAt: at(8),
407
+ endsAt: at(10, 0, 0, 0),
408
+ }),
409
+ ],
410
+ });
411
+ expect(result).toHaveLength(1);
412
+ expect(result[0]!.title).toBe("A");
413
+ });
414
+
415
+ test("override ending mid-second after event start -> still no substitution", () => {
416
+ // 10:00:00.900 truncates to 10:00:00 == event.start second -> isOnOrBefore true.
417
+ const result: Array<CalendarEvent> =
418
+ UserOverrideUtil.applyOverridesToEvents({
419
+ events: [baseEvent()],
420
+ overrides: [
421
+ override({
422
+ from: "A",
423
+ to: "B",
424
+ startsAt: at(8),
425
+ endsAt: at(10, 0, 0, 900),
426
+ }),
427
+ ],
428
+ });
429
+ expect(result).toHaveLength(1);
430
+ expect(result[0]!.title).toBe("A");
431
+ });
432
+
433
+ test("override ending one full second after event start -> 1s substitution", () => {
434
+ const result: Array<CalendarEvent> =
435
+ UserOverrideUtil.applyOverridesToEvents({
436
+ events: [baseEvent()],
437
+ overrides: [
438
+ override({
439
+ from: "A",
440
+ to: "B",
441
+ startsAt: at(8),
442
+ endsAt: at(10, 0, 1, 0),
443
+ }),
444
+ ],
445
+ });
446
+ expect(segs(result)).toEqual([
447
+ {
448
+ title: "B",
449
+ start: at(10).getTime(),
450
+ end: at(10, 0, 1).getTime(),
451
+ meta: true,
452
+ },
453
+ {
454
+ title: "A",
455
+ start: at(10, 0, 1).getTime(),
456
+ end: at(12).getTime(),
457
+ meta: false,
458
+ },
459
+ ]);
460
+ });
461
+
462
+ test("override starting one full second before event end -> trailing 1s substitution", () => {
463
+ const result: Array<CalendarEvent> =
464
+ UserOverrideUtil.applyOverridesToEvents({
465
+ events: [baseEvent()],
466
+ overrides: [
467
+ override({
468
+ from: "A",
469
+ to: "B",
470
+ startsAt: at(11, 59, 59, 0),
471
+ endsAt: at(13),
472
+ }),
473
+ ],
474
+ });
475
+ expect(segs(result)).toEqual([
476
+ {
477
+ title: "A",
478
+ start: at(10).getTime(),
479
+ end: at(11, 59, 59).getTime(),
480
+ meta: false,
481
+ },
482
+ {
483
+ title: "B",
484
+ start: at(11, 59, 59).getTime(),
485
+ end: at(12).getTime(),
486
+ meta: true,
487
+ },
488
+ ]);
489
+ });
490
+
491
+ test("no result segment ever has negative or zero duration across sub-second offsets", () => {
492
+ const offsets: Array<number> = [0, 1, 100, 499, 500, 501, 900, 999];
493
+ for (const startMs of offsets) {
494
+ for (const endMs of offsets) {
495
+ const result: Array<CalendarEvent> =
496
+ UserOverrideUtil.applyOverridesToEvents({
497
+ events: [baseEvent()],
498
+ overrides: [
499
+ override({
500
+ from: "A",
501
+ to: "B",
502
+ startsAt: at(10, 30, 0, startMs),
503
+ endsAt: at(11, 30, 0, endMs),
504
+ }),
505
+ ],
506
+ });
507
+ for (const e of result) {
508
+ expect(e.end.getTime()).toBeGreaterThan(e.start.getTime());
509
+ }
510
+ }
511
+ }
512
+ });
513
+ });
514
+
515
+ /*
516
+ * ---------------------------------------------------------------------------
517
+ * Override meta payload
518
+ * ---------------------------------------------------------------------------
519
+ */
520
+
521
+ describe("override meta payload", () => {
522
+ test("meta carries the UNCLAMPED override window, segment carries the CLAMPED window", () => {
523
+ const base: CalendarEvent = hourEvent("A", 10, 12);
524
+ const result: Array<CalendarEvent> =
525
+ UserOverrideUtil.applyOverridesToEvents({
526
+ events: [base],
527
+ // override window [8,16] fully covers event [10,12].
528
+ overrides: [hourOverride("A", "B", 8, 16)],
529
+ });
530
+ expect(result).toHaveLength(1);
531
+ const seg: CalendarEvent = result[0]!;
532
+ expect(seg.title).toBe("B");
533
+ // segment clamped to the event window.
534
+ expect(seg.start.getTime()).toBe(at(10).getTime());
535
+ expect(seg.end.getTime()).toBe(at(12).getTime());
536
+
537
+ const meta: OverrideEventMeta | null =
538
+ UserOverrideUtil.getOverrideMeta(seg);
539
+ expect(meta).not.toBeNull();
540
+ expect(meta!.isOverride).toBe(true);
541
+ expect(meta!.originalUserId).toBe("A");
542
+ expect(meta!.overrideUserId).toBe("B");
543
+ // meta timestamps are the ORIGINAL override window (not clamped).
544
+ expect(meta!.overrideStartsAt.getTime()).toBe(at(8).getTime());
545
+ expect(meta!.overrideEndsAt.getTime()).toBe(at(16).getTime());
546
+ });
547
+
548
+ test("non-substituted (pass-through) segments carry NO meta", () => {
549
+ const base: CalendarEvent = hourEvent("A", 8, 14);
550
+ const result: Array<CalendarEvent> =
551
+ UserOverrideUtil.applyOverridesToEvents({
552
+ events: [base],
553
+ overrides: [hourOverride("A", "B", 10, 12)],
554
+ });
555
+ const before: CalendarEvent = result.find((e: CalendarEvent) => {
556
+ return e.start.getTime() === at(8).getTime();
557
+ })!;
558
+ const after: CalendarEvent = result.find((e: CalendarEvent) => {
559
+ return e.start.getTime() === at(12).getTime();
560
+ })!;
561
+ expect(UserOverrideUtil.getOverrideMeta(before)).toBeNull();
562
+ expect(UserOverrideUtil.getOverrideMeta(after)).toBeNull();
563
+ expect(
564
+ (before as unknown as Record<string, unknown>)[OVERRIDE_META_KEY],
565
+ ).toBeUndefined();
566
+ });
567
+
568
+ test("getOverrideMeta returns null for malformed / partial meta", () => {
569
+ const noMeta: CalendarEvent = hourEvent("A", 8, 14);
570
+ expect(UserOverrideUtil.getOverrideMeta(noMeta)).toBeNull();
571
+
572
+ const falseMeta: CalendarEvent = {
573
+ ...hourEvent("A", 8, 14),
574
+ [OVERRIDE_META_KEY]: { isOverride: false } as unknown as never,
575
+ };
576
+ expect(UserOverrideUtil.getOverrideMeta(falseMeta)).toBeNull();
577
+
578
+ const primitiveMeta: CalendarEvent = {
579
+ ...hourEvent("A", 8, 14),
580
+ [OVERRIDE_META_KEY]: "nope" as unknown as never,
581
+ };
582
+ expect(UserOverrideUtil.getOverrideMeta(primitiveMeta)).toBeNull();
583
+ });
584
+ });
585
+
586
+ /*
587
+ * ---------------------------------------------------------------------------
588
+ * One override spanning multiple events
589
+ * ---------------------------------------------------------------------------
590
+ */
591
+
592
+ describe("one override spanning multiple events for the same user", () => {
593
+ test("override [9,15] over A[8,10], A[11,13], A[14,16]", () => {
594
+ const e1: CalendarEvent = hourEvent("A", 8, 10);
595
+ const e2: CalendarEvent = hourEvent("A", 11, 13);
596
+ const e3: CalendarEvent = hourEvent("A", 14, 16);
597
+ const result: Array<CalendarEvent> =
598
+ UserOverrideUtil.applyOverridesToEvents({
599
+ events: [e1, e2, e3],
600
+ overrides: [hourOverride("A", "B", 9, 15)],
601
+ });
602
+ expect(segs(result)).toEqual(
603
+ expectedHourSegs([
604
+ ["A", 8, 9, false],
605
+ ["B", 9, 10, true],
606
+ ["B", 11, 13, true],
607
+ ["B", 14, 15, true],
608
+ ["A", 15, 16, false],
609
+ ]),
610
+ );
611
+ // spot-check resolution, including the gaps between events.
612
+ expect(resolvedAt(result, at(8, 30))).toBe("A");
613
+ expect(resolvedAt(result, at(9, 30))).toBe("B");
614
+ expect(resolvedAt(result, at(10, 30))).toBeNull(); // gap between e1 and e2
615
+ expect(resolvedAt(result, at(12))).toBe("B");
616
+ expect(resolvedAt(result, at(13, 30))).toBeNull(); // gap between e2 and e3
617
+ expect(resolvedAt(result, at(14, 30))).toBe("B");
618
+ expect(resolvedAt(result, at(15, 30))).toBe("A");
619
+ // ids reassigned to a contiguous 1..5 sequence, chronological order preserved.
620
+ expect(
621
+ result.map((e: CalendarEvent) => {
622
+ return e.id;
623
+ }),
624
+ ).toEqual([1, 2, 3, 4, 5]);
625
+ });
626
+
627
+ test("override only touches events whose title matches the overridden user", () => {
628
+ const eA: CalendarEvent = hourEvent("A", 8, 12);
629
+ const eB: CalendarEvent = hourEvent("B", 12, 16);
630
+ const result: Array<CalendarEvent> =
631
+ UserOverrideUtil.applyOverridesToEvents({
632
+ events: [eA, eB],
633
+ overrides: [hourOverride("A", "X", 6, 20)], // spans both, but only A matches
634
+ });
635
+ expect(segs(result)).toEqual(
636
+ expectedHourSegs([
637
+ ["X", 8, 12, true],
638
+ ["B", 12, 16, false],
639
+ ]),
640
+ );
641
+ });
642
+ });
643
+
644
+ /*
645
+ * ---------------------------------------------------------------------------
646
+ * Multiple overrides on different users
647
+ * ---------------------------------------------------------------------------
648
+ */
649
+
650
+ describe("multiple overrides on different users", () => {
651
+ test("three independent overrides on three users each apply", () => {
652
+ const eA: CalendarEvent = hourEvent("A", 8, 12);
653
+ const eB: CalendarEvent = hourEvent("B", 12, 16);
654
+ const eC: CalendarEvent = hourEvent("C", 16, 20);
655
+ const result: Array<CalendarEvent> =
656
+ UserOverrideUtil.applyOverridesToEvents({
657
+ events: [eA, eB, eC],
658
+ overrides: [
659
+ hourOverride("A", "X", 9, 11),
660
+ hourOverride("B", "Y", 13, 15),
661
+ hourOverride("C", "Z", 17, 19),
662
+ ],
663
+ });
664
+ expect(segs(result)).toEqual(
665
+ expectedHourSegs([
666
+ ["A", 8, 9, false],
667
+ ["X", 9, 11, true],
668
+ ["A", 11, 12, false],
669
+ ["B", 12, 13, false],
670
+ ["Y", 13, 15, true],
671
+ ["B", 15, 16, false],
672
+ ["C", 16, 17, false],
673
+ ["Z", 17, 19, true],
674
+ ["C", 19, 20, false],
675
+ ]),
676
+ );
677
+ });
678
+
679
+ test("result is independent of the input order of overrides on different users", () => {
680
+ const build: (
681
+ overrides: Array<UserOverrideRecord>,
682
+ ) => Array<CalendarEvent> = (overrides: Array<UserOverrideRecord>) => {
683
+ return UserOverrideUtil.applyOverridesToEvents({
684
+ events: [hourEvent("A", 8, 12), hourEvent("B", 12, 16)],
685
+ overrides: overrides,
686
+ });
687
+ };
688
+ const ovA: UserOverrideRecord = hourOverride("A", "X", 9, 11);
689
+ const ovB: UserOverrideRecord = hourOverride("B", "Y", 13, 15);
690
+ expect(segs(build([ovA, ovB]))).toEqual(segs(build([ovB, ovA])));
691
+ });
692
+ });
693
+
694
+ /*
695
+ * ---------------------------------------------------------------------------
696
+ * Chained overrides (non-transitive) and swaps
697
+ * ---------------------------------------------------------------------------
698
+ */
699
+
700
+ describe("chained / swapped overrides are non-transitive", () => {
701
+ const chainForward: Array<UserOverrideRecord> = [
702
+ hourOverride("A", "B", 9, 13),
703
+ hourOverride("B", "C", 10, 12),
704
+ ];
705
+
706
+ test("A->B then B->C stays B (no double substitution) and is order independent", () => {
707
+ const forward: Array<CalendarEvent> =
708
+ UserOverrideUtil.applyOverridesToEvents({
709
+ events: [hourEvent("A", 8, 14)],
710
+ overrides: chainForward,
711
+ });
712
+ const reverse: Array<CalendarEvent> =
713
+ UserOverrideUtil.applyOverridesToEvents({
714
+ events: [hourEvent("A", 8, 14)],
715
+ overrides: [chainForward[1]!, chainForward[0]!],
716
+ });
717
+ const expected: Array<Seg> = expectedHourSegs([
718
+ ["A", 8, 9, false],
719
+ ["B", 9, 13, true],
720
+ ["A", 13, 14, false],
721
+ ]);
722
+ expect(segs(forward)).toEqual(expected);
723
+ expect(segs(reverse)).toEqual(expected);
724
+ expect(resolvedAt(forward, at(11))).toBe("B");
725
+ expect(resolvedAt(reverse, at(11))).toBe("B");
726
+ });
727
+
728
+ test("swap A->B and B->A: the freshly substituted B segment is frozen (stays B)", () => {
729
+ const overrides: Array<UserOverrideRecord> = [
730
+ hourOverride("A", "B", 9, 13),
731
+ hourOverride("B", "A", 9, 13),
732
+ ];
733
+ const forward: Array<CalendarEvent> =
734
+ UserOverrideUtil.applyOverridesToEvents({
735
+ events: [hourEvent("A", 8, 14)],
736
+ overrides: overrides,
737
+ });
738
+ const reverse: Array<CalendarEvent> =
739
+ UserOverrideUtil.applyOverridesToEvents({
740
+ events: [hourEvent("A", 8, 14)],
741
+ overrides: [overrides[1]!, overrides[0]!],
742
+ });
743
+ const expected: Array<Seg> = expectedHourSegs([
744
+ ["A", 8, 9, false],
745
+ ["B", 9, 13, true],
746
+ ["A", 13, 14, false],
747
+ ]);
748
+ expect(segs(forward)).toEqual(expected);
749
+ expect(segs(reverse)).toEqual(expected);
750
+ });
751
+
752
+ test("a separate original B event IS still overridden by B->A (only the substituted B is frozen)", () => {
753
+ /*
754
+ * event A[8,14] plus a genuinely-original B[14,20]. A->B on [9,13] creates a
755
+ * frozen B segment; B->A on [15,19] must still act on the ORIGINAL B event.
756
+ */
757
+ const result: Array<CalendarEvent> =
758
+ UserOverrideUtil.applyOverridesToEvents({
759
+ events: [hourEvent("A", 8, 14), hourEvent("B", 14, 20)],
760
+ overrides: [
761
+ hourOverride("A", "B", 9, 13),
762
+ hourOverride("B", "A", 15, 19),
763
+ ],
764
+ });
765
+ expect(segs(result)).toEqual(
766
+ expectedHourSegs([
767
+ ["A", 8, 9, false],
768
+ ["B", 9, 13, true], // substituted -> frozen, NOT re-routed to A
769
+ ["A", 13, 14, false],
770
+ ["B", 14, 15, false],
771
+ ["A", 15, 19, true], // original B re-routed to A
772
+ ["B", 19, 20, false],
773
+ ]),
774
+ );
775
+ });
776
+
777
+ test("three-link chain A->B, B->C, C->D never cascades past the first hop", () => {
778
+ const result: Array<CalendarEvent> =
779
+ UserOverrideUtil.applyOverridesToEvents({
780
+ events: [hourEvent("A", 8, 18)],
781
+ overrides: [
782
+ hourOverride("A", "B", 9, 17),
783
+ hourOverride("B", "C", 10, 16),
784
+ hourOverride("C", "D", 11, 15),
785
+ ],
786
+ });
787
+ expect(segs(result)).toEqual(
788
+ expectedHourSegs([
789
+ ["A", 8, 9, false],
790
+ ["B", 9, 17, true],
791
+ ["A", 17, 18, false],
792
+ ]),
793
+ );
794
+ });
795
+ });
796
+
797
+ /*
798
+ * ---------------------------------------------------------------------------
799
+ * Self override A->A
800
+ * ---------------------------------------------------------------------------
801
+ */
802
+
803
+ describe("self override A->A", () => {
804
+ test("A->A splits the window but keeps the title A and tags the middle with meta", () => {
805
+ /*
806
+ * NOTE: current behavior - a self-override still produces a distinct
807
+ * meta-tagged middle segment even though the resolved user is unchanged.
808
+ */
809
+ const result: Array<CalendarEvent> =
810
+ UserOverrideUtil.applyOverridesToEvents({
811
+ events: [hourEvent("A", 8, 14)],
812
+ overrides: [hourOverride("A", "A", 10, 12)],
813
+ });
814
+ expect(segs(result)).toEqual(
815
+ expectedHourSegs([
816
+ ["A", 8, 10, false],
817
+ ["A", 10, 12, true],
818
+ ["A", 12, 14, false],
819
+ ]),
820
+ );
821
+ // resolved user is A across the entire window.
822
+ expect(resolvedAt(result, at(9))).toBe("A");
823
+ expect(resolvedAt(result, at(11))).toBe("A");
824
+ expect(resolvedAt(result, at(13))).toBe("A");
825
+ const mid: OverrideEventMeta | null = UserOverrideUtil.getOverrideMeta(
826
+ result[1]!,
827
+ );
828
+ expect(mid).not.toBeNull();
829
+ expect(mid!.originalUserId).toBe("A");
830
+ expect(mid!.overrideUserId).toBe("A");
831
+ });
832
+ });
833
+
834
+ /*
835
+ * ---------------------------------------------------------------------------
836
+ * Global vs policy-scoped precedence
837
+ * ---------------------------------------------------------------------------
838
+ */
839
+
840
+ describe("global vs policy-scoped precedence", () => {
841
+ test("policy-scoped wins over global for the same user/window (input order: global first)", () => {
842
+ const result: Array<CalendarEvent> =
843
+ UserOverrideUtil.applyOverridesToEvents({
844
+ events: [hourEvent("A", 8, 14)],
845
+ overrides: [
846
+ hourOverride("A", "B", 9, 13, null), // global
847
+ hourOverride("A", "C", 9, 13, "policy-1"), // policy-scoped
848
+ ],
849
+ currentOnCallDutyPolicyId: "policy-1",
850
+ });
851
+ expect(resolvedAt(result, at(11))).toBe("C");
852
+ expect(segs(result)).toEqual(
853
+ expectedHourSegs([
854
+ ["A", 8, 9, false],
855
+ ["C", 9, 13, true],
856
+ ["A", 13, 14, false],
857
+ ]),
858
+ );
859
+ });
860
+
861
+ test("policy-scoped wins over global regardless of input order (input order: policy first)", () => {
862
+ const result: Array<CalendarEvent> =
863
+ UserOverrideUtil.applyOverridesToEvents({
864
+ events: [hourEvent("A", 8, 14)],
865
+ overrides: [
866
+ hourOverride("A", "C", 9, 13, "policy-1"), // policy-scoped
867
+ hourOverride("A", "B", 9, 13, null), // global
868
+ ],
869
+ currentOnCallDutyPolicyId: "policy-1",
870
+ });
871
+ expect(resolvedAt(result, at(11))).toBe("C");
872
+ });
873
+
874
+ test("wider global fills the window around the narrower policy-scoped substitution", () => {
875
+ /*
876
+ * policy A->C on [10,12] wins in that window; global A->B on [8,14] fills
877
+ * the leading and trailing A segments the policy override left behind.
878
+ */
879
+ const result: Array<CalendarEvent> =
880
+ UserOverrideUtil.applyOverridesToEvents({
881
+ events: [hourEvent("A", 8, 14)],
882
+ overrides: [
883
+ hourOverride("A", "B", 8, 14, null), // global (wide)
884
+ hourOverride("A", "C", 10, 12, "policy-1"), // policy (narrow)
885
+ ],
886
+ currentOnCallDutyPolicyId: "policy-1",
887
+ });
888
+ expect(segs(result)).toEqual(
889
+ expectedHourSegs([
890
+ ["B", 8, 10, true],
891
+ ["C", 10, 12, true],
892
+ ["B", 12, 14, true],
893
+ ]),
894
+ );
895
+ expect(resolvedAt(result, at(9))).toBe("B");
896
+ expect(resolvedAt(result, at(11))).toBe("C");
897
+ expect(resolvedAt(result, at(13))).toBe("B");
898
+ });
899
+
900
+ test("two policy-scoped overrides for the SAME policy on different users both apply", () => {
901
+ const result: Array<CalendarEvent> =
902
+ UserOverrideUtil.applyOverridesToEvents({
903
+ events: [hourEvent("A", 8, 12), hourEvent("B", 12, 16)],
904
+ overrides: [
905
+ hourOverride("A", "X", 9, 11, "policy-1"),
906
+ hourOverride("B", "Y", 13, 15, "policy-1"),
907
+ ],
908
+ currentOnCallDutyPolicyId: "policy-1",
909
+ });
910
+ expect(resolvedAt(result, at(10))).toBe("X");
911
+ expect(resolvedAt(result, at(14))).toBe("Y");
912
+ });
913
+ });
914
+
915
+ /*
916
+ * ---------------------------------------------------------------------------
917
+ * Policy scoping: filtering by context
918
+ * ---------------------------------------------------------------------------
919
+ */
920
+
921
+ describe("policy scoping filters overrides by caller context", () => {
922
+ test("policy-scoped override must NOT apply to a different policy", () => {
923
+ const result: Array<CalendarEvent> =
924
+ UserOverrideUtil.applyOverridesToEvents({
925
+ events: [hourEvent("A", 8, 14)],
926
+ overrides: [hourOverride("A", "B", 9, 13, "policy-1")],
927
+ currentOnCallDutyPolicyId: "policy-2",
928
+ });
929
+ // Not applicable -> events returned unchanged.
930
+ expect(result).toHaveLength(1);
931
+ expect(result[0]!.title).toBe("A");
932
+ expect(resolvedAt(result, at(11))).toBe("A");
933
+ });
934
+
935
+ test("with no policy context, only global overrides apply", () => {
936
+ const result: Array<CalendarEvent> =
937
+ UserOverrideUtil.applyOverridesToEvents({
938
+ events: [hourEvent("A", 8, 14)],
939
+ overrides: [
940
+ hourOverride("A", "P", 9, 11, "policy-1"), // policy-scoped -> filtered out
941
+ hourOverride("A", "G", 12, 13, null), // global -> applies
942
+ ],
943
+ // currentOnCallDutyPolicyId omitted (no context)
944
+ });
945
+ expect(resolvedAt(result, at(10))).toBe("A"); // policy-scoped dropped
946
+ expect(resolvedAt(result, at(12, 30))).toBe("G"); // global applied
947
+ expect(segs(result)).toEqual(
948
+ expectedHourSegs([
949
+ ["A", 8, 12, false],
950
+ ["G", 12, 13, true],
951
+ ["A", 13, 14, false],
952
+ ]),
953
+ );
954
+ });
955
+
956
+ test("matching policy context: both the matching policy override and the global apply", () => {
957
+ const result: Array<CalendarEvent> =
958
+ UserOverrideUtil.applyOverridesToEvents({
959
+ events: [hourEvent("A", 8, 12), hourEvent("B", 12, 16)],
960
+ overrides: [
961
+ hourOverride("A", "P", 9, 11, "policy-1"), // applies to A
962
+ hourOverride("B", "G", 13, 15, null), // global, applies to B
963
+ hourOverride("A", "Z", 9, 11, "policy-9"), // wrong policy -> dropped
964
+ ],
965
+ currentOnCallDutyPolicyId: "policy-1",
966
+ });
967
+ expect(resolvedAt(result, at(10))).toBe("P");
968
+ expect(resolvedAt(result, at(14))).toBe("G");
969
+ });
970
+ });
971
+
972
+ /*
973
+ * ---------------------------------------------------------------------------
974
+ * Event id reassignment & uniqueness
975
+ * ---------------------------------------------------------------------------
976
+ */
977
+
978
+ describe("event id reassignment and uniqueness", () => {
979
+ test("ids are reassigned to a unique contiguous 1..n sequence after a split", () => {
980
+ const result: Array<CalendarEvent> =
981
+ UserOverrideUtil.applyOverridesToEvents({
982
+ events: [
983
+ { ...hourEvent("A", 8, 14), id: 99 },
984
+ { ...hourEvent("B", 14, 20), id: 42 },
985
+ ],
986
+ overrides: [hourOverride("A", "X", 10, 12)],
987
+ });
988
+ const ids: Array<number> = result.map((e: CalendarEvent) => {
989
+ return e.id;
990
+ });
991
+ expect(ids).toEqual([1, 2, 3, 4]); // A|X|A + B
992
+ expect(new Set(ids).size).toBe(ids.length);
993
+ });
994
+
995
+ test("ids are reassigned even when an applicable override matches no event", () => {
996
+ /*
997
+ * Override targets user Z who is not on the roster. applicable is non-empty
998
+ * so the id reassignment path still runs.
999
+ */
1000
+ const result: Array<CalendarEvent> =
1001
+ UserOverrideUtil.applyOverridesToEvents({
1002
+ events: [
1003
+ { ...hourEvent("A", 8, 14), id: 77 },
1004
+ { ...hourEvent("B", 14, 20), id: 88 },
1005
+ ],
1006
+ overrides: [hourOverride("Z", "Q", 10, 12)],
1007
+ });
1008
+ expect(
1009
+ result.map((e: CalendarEvent) => {
1010
+ return e.id;
1011
+ }),
1012
+ ).toEqual([1, 2]);
1013
+ expect(
1014
+ result.map((e: CalendarEvent) => {
1015
+ return e.title;
1016
+ }),
1017
+ ).toEqual(["A", "B"]);
1018
+ });
1019
+
1020
+ test("no applicable overrides: the original array (and original ids) is returned unchanged", () => {
1021
+ const events: Array<CalendarEvent> = [
1022
+ { ...hourEvent("A", 8, 14), id: 77 },
1023
+ { ...hourEvent("B", 14, 20), id: 88 },
1024
+ ];
1025
+ const emptyResult: Array<CalendarEvent> =
1026
+ UserOverrideUtil.applyOverridesToEvents({
1027
+ events: events,
1028
+ overrides: [],
1029
+ });
1030
+ expect(emptyResult).toBe(events); // same reference, untouched
1031
+ expect(
1032
+ emptyResult.map((e: CalendarEvent) => {
1033
+ return e.id;
1034
+ }),
1035
+ ).toEqual([77, 88]);
1036
+
1037
+ // All overrides filtered out by policy scoping -> also unchanged.
1038
+ const filteredResult: Array<CalendarEvent> =
1039
+ UserOverrideUtil.applyOverridesToEvents({
1040
+ events: events,
1041
+ overrides: [hourOverride("A", "B", 10, 12, "other-policy")],
1042
+ currentOnCallDutyPolicyId: "policy-1",
1043
+ });
1044
+ expect(filteredResult).toBe(events);
1045
+ expect(
1046
+ filteredResult.map((e: CalendarEvent) => {
1047
+ return e.id;
1048
+ }),
1049
+ ).toEqual([77, 88]);
1050
+ });
1051
+ });
1052
+
1053
+ /*
1054
+ * ---------------------------------------------------------------------------
1055
+ * Structural invariants
1056
+ * ---------------------------------------------------------------------------
1057
+ */
1058
+
1059
+ describe("structural invariants", () => {
1060
+ test("non-title/boundary properties are preserved onto the produced segments", () => {
1061
+ const base: CalendarEvent = hourEvent("A", 8, 14, {
1062
+ color: "#123456",
1063
+ textColor: "#ffffff",
1064
+ desc: "primary shift",
1065
+ allDay: false,
1066
+ });
1067
+ const result: Array<CalendarEvent> =
1068
+ UserOverrideUtil.applyOverridesToEvents({
1069
+ events: [base],
1070
+ overrides: [hourOverride("A", "B", 10, 12)],
1071
+ });
1072
+ for (const e of result) {
1073
+ expect(e.color).toBe("#123456");
1074
+ expect(e.textColor).toBe("#ffffff");
1075
+ expect(e.desc).toBe("primary shift");
1076
+ expect(e.allDay).toBe(false);
1077
+ }
1078
+ });
1079
+
1080
+ test("the input events and their objects are not mutated", () => {
1081
+ const base: CalendarEvent = hourEvent("A", 8, 14);
1082
+ const snapshotTitle: string = base.title;
1083
+ const snapshotStart: number = base.start.getTime();
1084
+ const snapshotEnd: number = base.end.getTime();
1085
+ const snapshotId: number = base.id;
1086
+ const events: Array<CalendarEvent> = [base];
1087
+
1088
+ UserOverrideUtil.applyOverridesToEvents({
1089
+ events: events,
1090
+ overrides: [hourOverride("A", "B", 10, 12)],
1091
+ });
1092
+
1093
+ expect(events).toHaveLength(1);
1094
+ expect(base.title).toBe(snapshotTitle);
1095
+ expect(base.start.getTime()).toBe(snapshotStart);
1096
+ expect(base.end.getTime()).toBe(snapshotEnd);
1097
+ expect(base.id).toBe(snapshotId);
1098
+ expect(
1099
+ (base as unknown as Record<string, unknown>)[OVERRIDE_META_KEY],
1100
+ ).toBeUndefined();
1101
+ });
1102
+
1103
+ test("empty events with applicable overrides returns an empty array", () => {
1104
+ const result: Array<CalendarEvent> =
1105
+ UserOverrideUtil.applyOverridesToEvents({
1106
+ events: [],
1107
+ overrides: [hourOverride("A", "B", 10, 12)],
1108
+ });
1109
+ expect(result).toEqual([]);
1110
+ });
1111
+
1112
+ test("produced segments within a single event are contiguous and non-overlapping", () => {
1113
+ const result: Array<CalendarEvent> =
1114
+ UserOverrideUtil.applyOverridesToEvents({
1115
+ events: [hourEvent("A", 8, 14)],
1116
+ overrides: [hourOverride("A", "B", 10, 12)],
1117
+ });
1118
+ for (let i: number = 1; i < result.length; i++) {
1119
+ // each segment starts exactly where the previous ended.
1120
+ expect(result[i]!.start.getTime()).toBe(result[i - 1]!.end.getTime());
1121
+ }
1122
+ // union of segments covers the whole original window.
1123
+ expect(result[0]!.start.getTime()).toBe(at(8).getTime());
1124
+ expect(result[result.length - 1]!.end.getTime()).toBe(at(14).getTime());
1125
+ });
1126
+ });