@oneuptime/common 12.0.29 → 12.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
import OnCallShiftMaterializer, {
|
|
2
|
+
MaterializeResult,
|
|
3
|
+
MaterializedScheduleInfo,
|
|
4
|
+
MaterializedUserInfo,
|
|
5
|
+
} from "../../../../Server/Utils/OnCall/OnCallShiftMaterializer";
|
|
6
|
+
import OnCallDutyPolicyScheduleLayerUserService from "../../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
|
|
7
|
+
import OnCallDutyPolicyScheduleService from "../../../../Server/Services/OnCallDutyPolicyScheduleService";
|
|
8
|
+
import OnCallDutyPolicyUserOverrideService from "../../../../Server/Services/OnCallDutyPolicyUserOverrideService";
|
|
9
|
+
import UserService from "../../../../Server/Services/UserService";
|
|
10
|
+
import logger from "../../../../Server/Utils/Logger";
|
|
11
|
+
import CalendarEvent from "../../../../Types/Calendar/CalendarEvent";
|
|
12
|
+
import OneUptimeDate from "../../../../Types/Date";
|
|
13
|
+
import BadDataException from "../../../../Types/Exception/BadDataException";
|
|
14
|
+
import EventInterval from "../../../../Types/Events/EventInterval";
|
|
15
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
16
|
+
import { LayerProps } from "../../../../Types/OnCallDutyPolicy/Layer";
|
|
17
|
+
import {
|
|
18
|
+
MaterializedShift,
|
|
19
|
+
MaterializedShiftPolicy,
|
|
20
|
+
} from "../../../../Types/OnCallDutyPolicy/MaterializedShift";
|
|
21
|
+
import { OnCallShift } from "../../../../Types/OnCallDutyPolicy/ScheduleShiftUtil";
|
|
22
|
+
import {
|
|
23
|
+
at,
|
|
24
|
+
dailyRestriction,
|
|
25
|
+
rotation,
|
|
26
|
+
} from "../../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
|
|
27
|
+
import {
|
|
28
|
+
FakeDb,
|
|
29
|
+
emptyDb,
|
|
30
|
+
installFakeDb,
|
|
31
|
+
makeAttachment,
|
|
32
|
+
makeLayer,
|
|
33
|
+
makeLayerUser,
|
|
34
|
+
makeOverride,
|
|
35
|
+
makeProject,
|
|
36
|
+
makeSchedule,
|
|
37
|
+
makeUser,
|
|
38
|
+
oid,
|
|
39
|
+
} from "./OnCallResolverTestHarness";
|
|
40
|
+
import {
|
|
41
|
+
afterEach,
|
|
42
|
+
beforeEach,
|
|
43
|
+
describe,
|
|
44
|
+
expect,
|
|
45
|
+
jest,
|
|
46
|
+
test,
|
|
47
|
+
} from "@jest/globals";
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* OnCallShiftMaterializer: resolver segments -> MaterializedShift[].
|
|
51
|
+
*
|
|
52
|
+
* The fixtures are the ones the calendar-feed spec names — multi-layer with
|
|
53
|
+
* restricted hours, a partial override, a substitute who is not a member,
|
|
54
|
+
* a schedule attached to two policies with a policy-scoped override, and
|
|
55
|
+
* the audit "A[10:00-11:00], B[11:00:01-12:00]" seam case — and every one is
|
|
56
|
+
* CROSS-CHECKED against the service methods paging uses
|
|
57
|
+
* (getCurrentUserIdInSchedule / getEventByIndexInSchedule), which run the
|
|
58
|
+
* real code against the same in-memory rows.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const UTC: string = "UTC";
|
|
62
|
+
const PROJECT: string = "project-1";
|
|
63
|
+
const SCHEDULE: string = "schedule-1";
|
|
64
|
+
const SCHEDULE_2: string = "schedule-2";
|
|
65
|
+
|
|
66
|
+
const WINDOW_START: Date = at("2026-03-02T00:00:00Z");
|
|
67
|
+
const WINDOW_END: Date = at("2026-03-05T00:00:00Z");
|
|
68
|
+
const NOW: Date = at("2026-03-03T12:00:00Z");
|
|
69
|
+
|
|
70
|
+
function ids(list: Array<ObjectID>): Array<string> {
|
|
71
|
+
return list.map((id: ObjectID) => {
|
|
72
|
+
return id.toString();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* Fixture A: "Primary" A/B daily rotation restricted to 09:00-17:00 UTC over
|
|
78
|
+
* a 24x7 "Fallback" layer with C, attached to one policy.
|
|
79
|
+
*/
|
|
80
|
+
function multiLayerDb(): FakeDb {
|
|
81
|
+
const db: FakeDb = emptyDb();
|
|
82
|
+
|
|
83
|
+
db.projects.push(makeProject({ id: PROJECT, name: "Acme" }));
|
|
84
|
+
db.schedules.push(
|
|
85
|
+
makeSchedule({
|
|
86
|
+
id: SCHEDULE,
|
|
87
|
+
projectId: PROJECT,
|
|
88
|
+
name: "Payments",
|
|
89
|
+
timezone: UTC,
|
|
90
|
+
shiftConfigVersion: 4,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
db.layers.push(
|
|
95
|
+
makeLayer({
|
|
96
|
+
id: "layer-primary",
|
|
97
|
+
scheduleId: SCHEDULE,
|
|
98
|
+
projectId: PROJECT,
|
|
99
|
+
name: "Primary",
|
|
100
|
+
order: 1,
|
|
101
|
+
startsAt: at("2026-01-01T00:00:00Z"),
|
|
102
|
+
handOffTime: at("2026-01-01T09:00:00Z"),
|
|
103
|
+
rotation: rotation(EventInterval.Day, 1),
|
|
104
|
+
restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
|
|
105
|
+
}),
|
|
106
|
+
makeLayer({
|
|
107
|
+
id: "layer-fallback",
|
|
108
|
+
scheduleId: SCHEDULE,
|
|
109
|
+
projectId: PROJECT,
|
|
110
|
+
name: "Fallback",
|
|
111
|
+
order: 2,
|
|
112
|
+
startsAt: at("2026-01-05T00:00:00Z"),
|
|
113
|
+
handOffTime: at("2026-01-05T00:00:00Z"),
|
|
114
|
+
rotation: rotation(EventInterval.Week, 1),
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
db.layerUsers.push(
|
|
119
|
+
makeLayerUser({
|
|
120
|
+
id: "lu-a",
|
|
121
|
+
scheduleId: SCHEDULE,
|
|
122
|
+
layerId: "layer-primary",
|
|
123
|
+
projectId: PROJECT,
|
|
124
|
+
userId: "A",
|
|
125
|
+
order: 1,
|
|
126
|
+
}),
|
|
127
|
+
makeLayerUser({
|
|
128
|
+
id: "lu-b",
|
|
129
|
+
scheduleId: SCHEDULE,
|
|
130
|
+
layerId: "layer-primary",
|
|
131
|
+
projectId: PROJECT,
|
|
132
|
+
userId: "B",
|
|
133
|
+
order: 2,
|
|
134
|
+
}),
|
|
135
|
+
makeLayerUser({
|
|
136
|
+
id: "lu-c",
|
|
137
|
+
scheduleId: SCHEDULE,
|
|
138
|
+
layerId: "layer-fallback",
|
|
139
|
+
projectId: PROJECT,
|
|
140
|
+
userId: "C",
|
|
141
|
+
order: 1,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
db.attachments.push(
|
|
146
|
+
makeAttachment({
|
|
147
|
+
id: "att-1",
|
|
148
|
+
scheduleId: SCHEDULE,
|
|
149
|
+
projectId: PROJECT,
|
|
150
|
+
policyId: "P1",
|
|
151
|
+
policyName: "Payments Policy",
|
|
152
|
+
ruleId: "R1",
|
|
153
|
+
ruleName: "Primary rule",
|
|
154
|
+
ruleOrder: 1,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
db.users.push(
|
|
159
|
+
makeUser({
|
|
160
|
+
id: "A",
|
|
161
|
+
name: "Alice",
|
|
162
|
+
email: "alice@example.com",
|
|
163
|
+
timezone: "UTC",
|
|
164
|
+
}),
|
|
165
|
+
makeUser({ id: "B", email: "bob@example.com" }),
|
|
166
|
+
makeUser({
|
|
167
|
+
id: "C",
|
|
168
|
+
name: "Carol",
|
|
169
|
+
email: "carol@example.com",
|
|
170
|
+
timezone: "Europe/Stockholm",
|
|
171
|
+
}),
|
|
172
|
+
makeUser({ id: "E", name: "Erin" }),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return db;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/*
|
|
179
|
+
* Who the materialization says is on call at `instant` — in a policy's
|
|
180
|
+
* context when `policyId` is given: a policy-variant shift for that policy
|
|
181
|
+
* wins, otherwise the base (roster) shift applies, exactly as paging would
|
|
182
|
+
* resolve it.
|
|
183
|
+
*/
|
|
184
|
+
function coveringShift(
|
|
185
|
+
shifts: Array<MaterializedShift>,
|
|
186
|
+
instant: Date,
|
|
187
|
+
options?: { policyId?: string | undefined },
|
|
188
|
+
): MaterializedShift | undefined {
|
|
189
|
+
const covers: (shift: MaterializedShift) => boolean = (
|
|
190
|
+
shift: MaterializedShift,
|
|
191
|
+
): boolean => {
|
|
192
|
+
return (
|
|
193
|
+
shift.start.getTime() <= instant.getTime() &&
|
|
194
|
+
shift.end.getTime() > instant.getTime()
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (options?.policyId) {
|
|
199
|
+
const variant: MaterializedShift | undefined = shifts.find(
|
|
200
|
+
(shift: MaterializedShift) => {
|
|
201
|
+
return (
|
|
202
|
+
shift.policyVariantOf?.policyId === options.policyId && covers(shift)
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
if (variant) {
|
|
207
|
+
return variant;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return shifts.find((shift: MaterializedShift) => {
|
|
212
|
+
return !shift.policyVariantOf && covers(shift);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function baseShifts(
|
|
217
|
+
shifts: Array<MaterializedShift>,
|
|
218
|
+
): Array<MaterializedShift> {
|
|
219
|
+
return shifts.filter((shift: MaterializedShift) => {
|
|
220
|
+
return !shift.policyVariantOf;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function materializeSchedule(
|
|
225
|
+
db: FakeDb,
|
|
226
|
+
options?: { now?: Date; maxSimulationIterations?: number },
|
|
227
|
+
): Promise<MaterializeResult> {
|
|
228
|
+
installFakeDb(db);
|
|
229
|
+
return await OnCallShiftMaterializer.materializeForSchedule({
|
|
230
|
+
scheduleId: oid(SCHEDULE),
|
|
231
|
+
windowStart: WINDOW_START,
|
|
232
|
+
windowEnd: WINDOW_END,
|
|
233
|
+
now: options?.now ?? NOW,
|
|
234
|
+
maxSimulationIterations: options?.maxSimulationIterations,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function withinOneSecond(a: Date, b: Date): void {
|
|
239
|
+
expect(Math.abs(a.getTime() - b.getTime())).toBeLessThanOrEqual(1000);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
* The cross-check proper: at `now`, the person the materializer says is on
|
|
244
|
+
* call must be the person paging would resolve, and the current / next
|
|
245
|
+
* events paging computes must line up with the materialized shift boundaries
|
|
246
|
+
* (to within the engine's one-second seam, which the materializer removes).
|
|
247
|
+
*/
|
|
248
|
+
async function crossCheck(data: {
|
|
249
|
+
shifts: Array<MaterializedShift>;
|
|
250
|
+
now: Date;
|
|
251
|
+
policyId?: string | undefined;
|
|
252
|
+
}): Promise<void> {
|
|
253
|
+
jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(data.now);
|
|
254
|
+
|
|
255
|
+
const current: MaterializedShift | undefined = coveringShift(
|
|
256
|
+
data.shifts,
|
|
257
|
+
data.now,
|
|
258
|
+
{ policyId: data.policyId },
|
|
259
|
+
);
|
|
260
|
+
expect(current).toBeDefined();
|
|
261
|
+
|
|
262
|
+
const pagedUser: ObjectID | null =
|
|
263
|
+
await OnCallDutyPolicyScheduleService.getCurrentUserIdInSchedule(
|
|
264
|
+
oid(SCHEDULE),
|
|
265
|
+
{ onCallDutyPolicyId: data.policyId ? oid(data.policyId) : undefined },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(pagedUser?.toString()).toBe(current!.userId);
|
|
269
|
+
|
|
270
|
+
const events: Array<CalendarEvent> =
|
|
271
|
+
await OnCallDutyPolicyScheduleService.getEventByIndexInSchedule({
|
|
272
|
+
scheduleId: oid(SCHEDULE),
|
|
273
|
+
getNumberOfEvents: 2,
|
|
274
|
+
onCallDutyPolicyId: data.policyId ? oid(data.policyId) : undefined,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
278
|
+
expect(events[0]!.title).toBe(current!.userId);
|
|
279
|
+
withinOneSecond(events[0]!.end, current!.end);
|
|
280
|
+
|
|
281
|
+
if (events[1]) {
|
|
282
|
+
const next: MaterializedShift | undefined = coveringShift(
|
|
283
|
+
data.shifts,
|
|
284
|
+
new Date(current!.end.getTime() + 1000),
|
|
285
|
+
{ policyId: data.policyId },
|
|
286
|
+
);
|
|
287
|
+
expect(next).toBeDefined();
|
|
288
|
+
expect(events[1]!.title).toBe(next!.userId);
|
|
289
|
+
withinOneSecond(events[1]!.start, next!.start);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
describe("OnCallShiftMaterializer", () => {
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
296
|
+
return undefined;
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
afterEach(() => {
|
|
301
|
+
jest.restoreAllMocks();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("multi-layer schedule with restricted hours (fixture A)", () => {
|
|
305
|
+
test("one shift per active block, seam-normalised, touching, no overlaps, all inside the window", async () => {
|
|
306
|
+
const result: MaterializeResult =
|
|
307
|
+
await materializeSchedule(multiLayerDb());
|
|
308
|
+
const shifts: Array<MaterializedShift> = baseShifts(result.shifts);
|
|
309
|
+
|
|
310
|
+
expect(result.truncated).toBe(false);
|
|
311
|
+
expect(shifts.length).toBeGreaterThan(3);
|
|
312
|
+
|
|
313
|
+
for (const shift of shifts) {
|
|
314
|
+
expect(shift.start.getTime()).toBeLessThan(WINDOW_END.getTime());
|
|
315
|
+
expect(shift.end.getTime()).toBeGreaterThan(WINDOW_START.getTime());
|
|
316
|
+
expect(shift.end.getTime()).toBeGreaterThan(shift.start.getTime());
|
|
317
|
+
// Minute-aligned after seam normalisation.
|
|
318
|
+
expect(shift.start.getUTCSeconds()).toBe(0);
|
|
319
|
+
expect(shift.start.getUTCMilliseconds()).toBe(0);
|
|
320
|
+
expect(shift.end.getUTCSeconds()).toBe(0);
|
|
321
|
+
expect(shift.coverageSeconds).toBe(
|
|
322
|
+
Math.round((shift.end.getTime() - shift.start.getTime()) / 1000),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (let i: number = 1; i < shifts.length; i++) {
|
|
327
|
+
const previous: MaterializedShift = shifts[i - 1]!;
|
|
328
|
+
const current: MaterializedShift = shifts[i]!;
|
|
329
|
+
// Sorted by start, touching exactly, never overlapping.
|
|
330
|
+
expect(current.start.getTime()).toBeGreaterThanOrEqual(
|
|
331
|
+
previous.start.getTime(),
|
|
332
|
+
);
|
|
333
|
+
expect(current.start.getTime()).toBe(previous.end.getTime());
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("primary shifts are 09:00-17:00 by A or B with layer identity; the fallback fills the rest with C", async () => {
|
|
338
|
+
const result: MaterializeResult =
|
|
339
|
+
await materializeSchedule(multiLayerDb());
|
|
340
|
+
const shifts: Array<MaterializedShift> = baseShifts(result.shifts);
|
|
341
|
+
|
|
342
|
+
const primary: Array<MaterializedShift> = shifts.filter(
|
|
343
|
+
(shift: MaterializedShift) => {
|
|
344
|
+
return shift.layerName === "Primary";
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
const fallback: Array<MaterializedShift> = shifts.filter(
|
|
348
|
+
(shift: MaterializedShift) => {
|
|
349
|
+
return shift.layerName === "Fallback";
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
expect(primary).toHaveLength(3); // 03-02, 03-03, 03-04
|
|
354
|
+
for (const shift of primary) {
|
|
355
|
+
expect(shift.layerId).toBe("layer-primary");
|
|
356
|
+
expect(shift.start.getUTCHours()).toBe(9);
|
|
357
|
+
expect(shift.end.getUTCHours()).toBe(17);
|
|
358
|
+
expect(shift.coverageSeconds).toBe(8 * 3600);
|
|
359
|
+
expect(["A", "B"]).toContain(shift.userId);
|
|
360
|
+
}
|
|
361
|
+
expect(primary[0]!.userId).not.toBe(primary[1]!.userId);
|
|
362
|
+
expect(primary[1]!.userId).not.toBe(primary[2]!.userId);
|
|
363
|
+
|
|
364
|
+
expect(fallback.length).toBeGreaterThan(0);
|
|
365
|
+
for (const shift of fallback) {
|
|
366
|
+
expect(shift.layerId).toBe("layer-fallback");
|
|
367
|
+
expect(shift.userId).toBe("C");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(coveringShift(shifts, at("2026-03-03T12:00:00Z"))!.layerName).toBe(
|
|
371
|
+
"Primary",
|
|
372
|
+
);
|
|
373
|
+
expect(coveringShift(shifts, at("2026-03-03T20:00:00Z"))!.userId).toBe(
|
|
374
|
+
"C",
|
|
375
|
+
);
|
|
376
|
+
expect(coveringShift(shifts, at("2026-03-03T03:00:00Z"))!.userId).toBe(
|
|
377
|
+
"C",
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("carries schedule, project, policy, version, lastModifiedAt and isPast", async () => {
|
|
382
|
+
const result: MaterializeResult =
|
|
383
|
+
await materializeSchedule(multiLayerDb());
|
|
384
|
+
|
|
385
|
+
for (const shift of result.shifts) {
|
|
386
|
+
expect(shift.projectId).toBe(PROJECT);
|
|
387
|
+
expect(shift.projectName).toBe("Acme");
|
|
388
|
+
expect(shift.scheduleId).toBe(SCHEDULE);
|
|
389
|
+
expect(shift.scheduleName).toBe("Payments");
|
|
390
|
+
expect(shift.scheduleTimezone).toBe(UTC);
|
|
391
|
+
expect(shift.shiftConfigVersion).toBe(4);
|
|
392
|
+
expect(shift.lastModifiedAt.toISOString()).toBe(
|
|
393
|
+
"2026-08-01T10:00:00.000Z",
|
|
394
|
+
);
|
|
395
|
+
expect(shift.policies).toEqual([
|
|
396
|
+
{
|
|
397
|
+
policyId: "P1",
|
|
398
|
+
policyName: "Payments Policy",
|
|
399
|
+
ruleId: "R1",
|
|
400
|
+
ruleName: "Primary rule",
|
|
401
|
+
ruleOrder: 1,
|
|
402
|
+
},
|
|
403
|
+
]);
|
|
404
|
+
expect(shift.isPast).toBe(shift.start.getTime() < NOW.getTime());
|
|
405
|
+
expect(shift.override).toBeUndefined();
|
|
406
|
+
expect(shift.policyVariantOf).toBeUndefined();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
expect(
|
|
410
|
+
result.shifts.some((shift: MaterializedShift) => {
|
|
411
|
+
return shift.isPast;
|
|
412
|
+
}),
|
|
413
|
+
).toBe(true);
|
|
414
|
+
expect(
|
|
415
|
+
result.shifts.some((shift: MaterializedShift) => {
|
|
416
|
+
return !shift.isPast;
|
|
417
|
+
}),
|
|
418
|
+
).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("names and timezones come from ONE user lookup; email is the fallback, unknown users are 'Unnamed user'", async () => {
|
|
422
|
+
const result: MaterializeResult =
|
|
423
|
+
await materializeSchedule(multiLayerDb());
|
|
424
|
+
|
|
425
|
+
expect(UserService.findBy).toHaveBeenCalledTimes(1);
|
|
426
|
+
|
|
427
|
+
const byUser: Record<string, string> = {};
|
|
428
|
+
for (const shift of result.shifts) {
|
|
429
|
+
byUser[shift.userId] = shift.userName;
|
|
430
|
+
}
|
|
431
|
+
expect(byUser["A"]).toBe("Alice");
|
|
432
|
+
expect(byUser["B"]).toBe("bob@example.com");
|
|
433
|
+
expect(byUser["C"]).toBe("Carol");
|
|
434
|
+
|
|
435
|
+
const carol: MaterializedUserInfo | undefined = result.users.find(
|
|
436
|
+
(u: MaterializedUserInfo) => {
|
|
437
|
+
return u.userId === "C";
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
expect(carol).toEqual({
|
|
441
|
+
userId: "C",
|
|
442
|
+
userName: "Carol",
|
|
443
|
+
email: "carol@example.com",
|
|
444
|
+
timezone: "Europe/Stockholm",
|
|
445
|
+
});
|
|
446
|
+
const bob: MaterializedUserInfo | undefined = result.users.find(
|
|
447
|
+
(u: MaterializedUserInfo) => {
|
|
448
|
+
return u.userId === "B";
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
expect(bob).toEqual({
|
|
452
|
+
userId: "B",
|
|
453
|
+
userName: "bob@example.com",
|
|
454
|
+
email: "bob@example.com",
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("exposes per-schedule info (layer props for the coverage envelope, attached policies, members)", async () => {
|
|
459
|
+
const result: MaterializeResult =
|
|
460
|
+
await materializeSchedule(multiLayerDb());
|
|
461
|
+
|
|
462
|
+
expect(result.schedules).toHaveLength(1);
|
|
463
|
+
const info: MaterializedScheduleInfo = result.schedules[0]!;
|
|
464
|
+
expect(info.scheduleId).toBe(SCHEDULE);
|
|
465
|
+
expect(info.scheduleName).toBe("Payments");
|
|
466
|
+
expect(info.projectName).toBe("Acme");
|
|
467
|
+
expect(info.scheduleTimezone).toBe(UTC);
|
|
468
|
+
expect(info.shiftConfigVersion).toBe(4);
|
|
469
|
+
expect(info.truncated).toBe(false);
|
|
470
|
+
expect(
|
|
471
|
+
info.layerProps.map((l: LayerProps) => {
|
|
472
|
+
return l.layerId;
|
|
473
|
+
}),
|
|
474
|
+
).toEqual(["layer-primary", "layer-fallback"]);
|
|
475
|
+
expect(info.scheduleUserIds).toEqual(["A", "B", "C"]);
|
|
476
|
+
expect(info.attachedPolicies).toHaveLength(1);
|
|
477
|
+
expect(result.generatedAt).toBe(NOW);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("cross-check: agrees with getCurrentUserIdInSchedule / getEventByIndexInSchedule during and outside office hours", async () => {
|
|
481
|
+
const result: MaterializeResult =
|
|
482
|
+
await materializeSchedule(multiLayerDb());
|
|
483
|
+
|
|
484
|
+
await crossCheck({
|
|
485
|
+
shifts: result.shifts,
|
|
486
|
+
now: at("2026-03-03T12:00:00Z"),
|
|
487
|
+
policyId: "P1",
|
|
488
|
+
});
|
|
489
|
+
await crossCheck({
|
|
490
|
+
shifts: result.shifts,
|
|
491
|
+
now: at("2026-03-03T20:00:00Z"),
|
|
492
|
+
policyId: "P1",
|
|
493
|
+
});
|
|
494
|
+
await crossCheck({
|
|
495
|
+
shifts: result.shifts,
|
|
496
|
+
now: at("2026-03-04T08:59:00Z"),
|
|
497
|
+
policyId: "P1",
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("shift identity and content hash", () => {
|
|
503
|
+
test("shiftKey is schedule + seam-normalised start; hashes are deterministic across runs", async () => {
|
|
504
|
+
const first: MaterializeResult =
|
|
505
|
+
await materializeSchedule(multiLayerDb());
|
|
506
|
+
jest.restoreAllMocks();
|
|
507
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
508
|
+
return undefined;
|
|
509
|
+
});
|
|
510
|
+
const second: MaterializeResult =
|
|
511
|
+
await materializeSchedule(multiLayerDb());
|
|
512
|
+
|
|
513
|
+
expect(
|
|
514
|
+
first.shifts.map((s: MaterializedShift) => {
|
|
515
|
+
return s.shiftKey;
|
|
516
|
+
}),
|
|
517
|
+
).toEqual(
|
|
518
|
+
second.shifts.map((s: MaterializedShift) => {
|
|
519
|
+
return s.shiftKey;
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
expect(
|
|
523
|
+
first.shifts.map((s: MaterializedShift) => {
|
|
524
|
+
return s.contentHash;
|
|
525
|
+
}),
|
|
526
|
+
).toEqual(
|
|
527
|
+
second.shifts.map((s: MaterializedShift) => {
|
|
528
|
+
return s.contentHash;
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
for (const shift of first.shifts) {
|
|
533
|
+
expect(shift.shiftKey).toBe(
|
|
534
|
+
`${SCHEDULE}:${Math.floor(shift.start.getTime() / 1000)}`,
|
|
535
|
+
);
|
|
536
|
+
expect(shift.contentHash).toMatch(/^[0-9a-f]{64}$/);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Keys are unique.
|
|
540
|
+
expect(
|
|
541
|
+
new Set(
|
|
542
|
+
first.shifts.map((s: MaterializedShift) => {
|
|
543
|
+
return s.shiftKey;
|
|
544
|
+
}),
|
|
545
|
+
).size,
|
|
546
|
+
).toBe(first.shifts.length);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("a display-name change alters the hash of that user's shifts only", async () => {
|
|
550
|
+
const before: MaterializeResult =
|
|
551
|
+
await materializeSchedule(multiLayerDb());
|
|
552
|
+
jest.restoreAllMocks();
|
|
553
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
554
|
+
return undefined;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const db: FakeDb = multiLayerDb();
|
|
558
|
+
db.users[0]!["name"] = "Alicia";
|
|
559
|
+
const after: MaterializeResult = await materializeSchedule(db);
|
|
560
|
+
|
|
561
|
+
expect(after.shifts).toHaveLength(before.shifts.length);
|
|
562
|
+
before.shifts.forEach((shift: MaterializedShift, index: number) => {
|
|
563
|
+
const other: MaterializedShift = after.shifts[index]!;
|
|
564
|
+
expect(other.shiftKey).toBe(shift.shiftKey);
|
|
565
|
+
if (shift.userId === "A") {
|
|
566
|
+
expect(other.contentHash).not.toBe(shift.contentHash);
|
|
567
|
+
expect(other.userName).toBe("Alicia");
|
|
568
|
+
} else {
|
|
569
|
+
expect(other.contentHash).toBe(shift.contentHash);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("computeContentHash ignores shiftKey / version / lastModifiedAt and reacts to content", () => {
|
|
575
|
+
const base: MaterializedShift = {
|
|
576
|
+
shiftKey: "k",
|
|
577
|
+
contentHash: "",
|
|
578
|
+
projectId: PROJECT,
|
|
579
|
+
scheduleId: SCHEDULE,
|
|
580
|
+
scheduleName: "Payments",
|
|
581
|
+
userId: "A",
|
|
582
|
+
userName: "Alice",
|
|
583
|
+
start: at("2026-03-03T09:00:00Z"),
|
|
584
|
+
end: at("2026-03-03T17:00:00Z"),
|
|
585
|
+
coverageSeconds: 8 * 3600,
|
|
586
|
+
policies: [],
|
|
587
|
+
isPast: false,
|
|
588
|
+
lastModifiedAt: at("2026-01-01T00:00:00Z"),
|
|
589
|
+
shiftConfigVersion: 1,
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const hash: string = OnCallShiftMaterializer.computeContentHash(base);
|
|
593
|
+
|
|
594
|
+
const identityOnlyChange: MaterializedShift = {
|
|
595
|
+
...base,
|
|
596
|
+
shiftKey: "other",
|
|
597
|
+
lastModifiedAt: at("2027-01-01T00:00:00Z"),
|
|
598
|
+
shiftConfigVersion: 99,
|
|
599
|
+
};
|
|
600
|
+
expect(
|
|
601
|
+
OnCallShiftMaterializer.computeContentHash(identityOnlyChange),
|
|
602
|
+
).toBe(hash);
|
|
603
|
+
|
|
604
|
+
expect(
|
|
605
|
+
OnCallShiftMaterializer.computeContentHash({
|
|
606
|
+
...base,
|
|
607
|
+
end: at("2026-03-03T18:00:00Z"),
|
|
608
|
+
}),
|
|
609
|
+
).not.toBe(hash);
|
|
610
|
+
expect(
|
|
611
|
+
OnCallShiftMaterializer.computeContentHash({ ...base, userId: "B" }),
|
|
612
|
+
).not.toBe(hash);
|
|
613
|
+
expect(
|
|
614
|
+
OnCallShiftMaterializer.computeContentHash({
|
|
615
|
+
...base,
|
|
616
|
+
layerName: "Primary",
|
|
617
|
+
}),
|
|
618
|
+
).not.toBe(hash);
|
|
619
|
+
expect(
|
|
620
|
+
OnCallShiftMaterializer.computeContentHash({
|
|
621
|
+
...base,
|
|
622
|
+
policies: [
|
|
623
|
+
{
|
|
624
|
+
policyId: "P1",
|
|
625
|
+
policyName: "x",
|
|
626
|
+
ruleId: "R1",
|
|
627
|
+
ruleName: "r",
|
|
628
|
+
ruleOrder: 1,
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
}),
|
|
632
|
+
).not.toBe(hash);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe("partial override (fixture B)", () => {
|
|
637
|
+
const OVERRIDE_START: Date = at("2026-03-03T11:00:00Z");
|
|
638
|
+
const OVERRIDE_END: Date = at("2026-03-03T13:00:00Z");
|
|
639
|
+
|
|
640
|
+
async function overrideDb(): Promise<{ db: FakeDb; original: string }> {
|
|
641
|
+
const baseline: MaterializeResult =
|
|
642
|
+
await materializeSchedule(multiLayerDb());
|
|
643
|
+
const original: string = coveringShift(baseline.shifts, NOW)!.userId;
|
|
644
|
+
jest.restoreAllMocks();
|
|
645
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
646
|
+
return undefined;
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const db: FakeDb = multiLayerDb();
|
|
650
|
+
db.overrides.push(
|
|
651
|
+
makeOverride({
|
|
652
|
+
id: "ov-1",
|
|
653
|
+
projectId: PROJECT,
|
|
654
|
+
overrideUserId: original,
|
|
655
|
+
routeAlertsToUserId: "D",
|
|
656
|
+
startsAt: OVERRIDE_START,
|
|
657
|
+
endsAt: OVERRIDE_END,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
return { db, original };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
test("splits the day into X / D / X with three touching shifts and override provenance on the middle one", async () => {
|
|
664
|
+
const { db, original } = await overrideDb();
|
|
665
|
+
const result: MaterializeResult = await materializeSchedule(db);
|
|
666
|
+
|
|
667
|
+
const day: Array<MaterializedShift> = baseShifts(result.shifts).filter(
|
|
668
|
+
(shift: MaterializedShift) => {
|
|
669
|
+
return (
|
|
670
|
+
shift.layerName === "Primary" && shift.start.getUTCDate() === 3
|
|
671
|
+
);
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
expect(
|
|
676
|
+
day.map((s: MaterializedShift) => {
|
|
677
|
+
return [s.userId, s.start.toISOString(), s.end.toISOString()];
|
|
678
|
+
}),
|
|
679
|
+
).toEqual([
|
|
680
|
+
[original, "2026-03-03T09:00:00.000Z", "2026-03-03T11:00:00.000Z"],
|
|
681
|
+
["D", "2026-03-03T11:00:00.000Z", "2026-03-03T13:00:00.000Z"],
|
|
682
|
+
[original, "2026-03-03T13:00:00.000Z", "2026-03-03T17:00:00.000Z"],
|
|
683
|
+
]);
|
|
684
|
+
|
|
685
|
+
const [before, during, after] = day as [
|
|
686
|
+
MaterializedShift,
|
|
687
|
+
MaterializedShift,
|
|
688
|
+
MaterializedShift,
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
expect(before.override).toBeUndefined();
|
|
692
|
+
expect(after.override).toBeUndefined();
|
|
693
|
+
expect(during.override).toEqual({
|
|
694
|
+
originalUserId: original,
|
|
695
|
+
originalUserName: original === "A" ? "Alice" : "bob@example.com",
|
|
696
|
+
overrideStartsAt: OVERRIDE_START,
|
|
697
|
+
overrideEndsAt: OVERRIDE_END,
|
|
698
|
+
});
|
|
699
|
+
expect(during.override!.onCallDutyPolicyId).toBeUndefined();
|
|
700
|
+
expect(during.userName).toBe("Unnamed user"); // D has no user row
|
|
701
|
+
expect(during.shiftKey).toBe(
|
|
702
|
+
`${SCHEDULE}:${Math.floor(OVERRIDE_START.getTime() / 1000)}`,
|
|
703
|
+
);
|
|
704
|
+
expect(
|
|
705
|
+
new Set(
|
|
706
|
+
day.map((s: MaterializedShift) => {
|
|
707
|
+
return s.shiftKey;
|
|
708
|
+
}),
|
|
709
|
+
).size,
|
|
710
|
+
).toBe(3);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("cross-check: paging pages the substitute during the override and the original outside it", async () => {
|
|
714
|
+
const { db } = await overrideDb();
|
|
715
|
+
const result: MaterializeResult = await materializeSchedule(db);
|
|
716
|
+
|
|
717
|
+
await crossCheck({
|
|
718
|
+
shifts: result.shifts,
|
|
719
|
+
now: at("2026-03-03T12:00:00Z"),
|
|
720
|
+
policyId: "P1",
|
|
721
|
+
});
|
|
722
|
+
await crossCheck({
|
|
723
|
+
shifts: result.shifts,
|
|
724
|
+
now: at("2026-03-03T10:30:00Z"),
|
|
725
|
+
policyId: "P1",
|
|
726
|
+
});
|
|
727
|
+
await crossCheck({
|
|
728
|
+
shifts: result.shifts,
|
|
729
|
+
now: at("2026-03-03T13:30:00Z"),
|
|
730
|
+
policyId: "P1",
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("substitute who is NOT a member: covering shift appears only with includeCoveringShifts", async () => {
|
|
735
|
+
const { db } = await overrideDb();
|
|
736
|
+
installFakeDb(db);
|
|
737
|
+
|
|
738
|
+
const covering: MaterializeResult =
|
|
739
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
740
|
+
userId: oid("D"),
|
|
741
|
+
windowStart: WINDOW_START,
|
|
742
|
+
windowEnd: WINDOW_END,
|
|
743
|
+
now: NOW,
|
|
744
|
+
includeCoveringShifts: true,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
expect(covering.shifts).toHaveLength(1);
|
|
748
|
+
expect(covering.shifts[0]!.userId).toBe("D");
|
|
749
|
+
expect(covering.shifts[0]!.override?.originalUserId).toBeDefined();
|
|
750
|
+
expect(
|
|
751
|
+
covering.schedules.map((s: MaterializedScheduleInfo) => {
|
|
752
|
+
return s.scheduleId;
|
|
753
|
+
}),
|
|
754
|
+
).toEqual([SCHEDULE]);
|
|
755
|
+
|
|
756
|
+
const own: MaterializeResult =
|
|
757
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
758
|
+
userId: oid("D"),
|
|
759
|
+
windowStart: WINDOW_START,
|
|
760
|
+
windowEnd: WINDOW_END,
|
|
761
|
+
now: NOW,
|
|
762
|
+
includeCoveringShifts: false,
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
expect(own.shifts).toEqual([]);
|
|
766
|
+
expect(own.schedules).toEqual([]);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("the overridden user's own feed loses the middle block", async () => {
|
|
770
|
+
const { db, original } = await overrideDb();
|
|
771
|
+
installFakeDb(db);
|
|
772
|
+
|
|
773
|
+
const result: MaterializeResult =
|
|
774
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
775
|
+
userId: oid(original),
|
|
776
|
+
windowStart: WINDOW_START,
|
|
777
|
+
windowEnd: WINDOW_END,
|
|
778
|
+
now: NOW,
|
|
779
|
+
includeCoveringShifts: true,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
for (const shift of result.shifts) {
|
|
783
|
+
expect(shift.userId).toBe(original);
|
|
784
|
+
}
|
|
785
|
+
expect(coveringShift(result.shifts, NOW)).toBeUndefined();
|
|
786
|
+
expect(
|
|
787
|
+
coveringShift(result.shifts, at("2026-03-03T10:00:00Z")),
|
|
788
|
+
).toBeDefined();
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe("multi-policy schedule with a policy-scoped override (fixture C)", () => {
|
|
793
|
+
const OVERRIDE_START: Date = at("2026-03-03T09:00:00Z");
|
|
794
|
+
const OVERRIDE_END: Date = at("2026-03-03T17:00:00Z");
|
|
795
|
+
|
|
796
|
+
async function twoPolicyDb(): Promise<{ db: FakeDb; original: string }> {
|
|
797
|
+
const baseline: MaterializeResult =
|
|
798
|
+
await materializeSchedule(multiLayerDb());
|
|
799
|
+
const original: string = coveringShift(baseline.shifts, NOW)!.userId;
|
|
800
|
+
jest.restoreAllMocks();
|
|
801
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
802
|
+
return undefined;
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const db: FakeDb = multiLayerDb();
|
|
806
|
+
db.attachments.push(
|
|
807
|
+
makeAttachment({
|
|
808
|
+
id: "att-2",
|
|
809
|
+
scheduleId: SCHEDULE,
|
|
810
|
+
projectId: PROJECT,
|
|
811
|
+
policyId: "P2",
|
|
812
|
+
policyName: "Checkout Policy",
|
|
813
|
+
ruleId: "R2",
|
|
814
|
+
ruleName: "Checkout rule",
|
|
815
|
+
ruleOrder: 1,
|
|
816
|
+
}),
|
|
817
|
+
);
|
|
818
|
+
db.overrides.push(
|
|
819
|
+
makeOverride({
|
|
820
|
+
id: "ov-scoped",
|
|
821
|
+
projectId: PROJECT,
|
|
822
|
+
overrideUserId: original,
|
|
823
|
+
routeAlertsToUserId: "E",
|
|
824
|
+
startsAt: OVERRIDE_START,
|
|
825
|
+
endsAt: OVERRIDE_END,
|
|
826
|
+
policyId: "P1",
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
829
|
+
return { db, original };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
test("emits the base shift for the rostered user plus ONE policy-variant shift for the substitute", async () => {
|
|
833
|
+
const { db, original } = await twoPolicyDb();
|
|
834
|
+
const result: MaterializeResult = await materializeSchedule(db);
|
|
835
|
+
|
|
836
|
+
const base: MaterializedShift = coveringShift(result.shifts, NOW)!;
|
|
837
|
+
expect(base.userId).toBe(original);
|
|
838
|
+
expect(base.override).toBeUndefined();
|
|
839
|
+
expect(base.policyVariantOf).toBeUndefined();
|
|
840
|
+
expect(
|
|
841
|
+
base.policies
|
|
842
|
+
.map((p: MaterializedShiftPolicy) => {
|
|
843
|
+
return p.policyId;
|
|
844
|
+
})
|
|
845
|
+
.sort(),
|
|
846
|
+
).toEqual(["P1", "P2"]);
|
|
847
|
+
|
|
848
|
+
const variants: Array<MaterializedShift> = result.shifts.filter(
|
|
849
|
+
(s: MaterializedShift) => {
|
|
850
|
+
return Boolean(s.policyVariantOf);
|
|
851
|
+
},
|
|
852
|
+
);
|
|
853
|
+
expect(variants).toHaveLength(1);
|
|
854
|
+
|
|
855
|
+
const variant: MaterializedShift = variants[0]!;
|
|
856
|
+
expect(variant.userId).toBe("E");
|
|
857
|
+
expect(variant.userName).toBe("Erin");
|
|
858
|
+
expect(variant.start).toEqual(OVERRIDE_START);
|
|
859
|
+
expect(variant.end).toEqual(OVERRIDE_END);
|
|
860
|
+
expect(variant.policyVariantOf).toEqual({
|
|
861
|
+
policyId: "P1",
|
|
862
|
+
policyName: "Payments Policy",
|
|
863
|
+
globalUserId: original,
|
|
864
|
+
});
|
|
865
|
+
expect(variant.override).toMatchObject({
|
|
866
|
+
originalUserId: original,
|
|
867
|
+
overrideStartsAt: OVERRIDE_START,
|
|
868
|
+
overrideEndsAt: OVERRIDE_END,
|
|
869
|
+
onCallDutyPolicyId: "P1",
|
|
870
|
+
});
|
|
871
|
+
expect(variant.shiftKey).toBe(
|
|
872
|
+
`${SCHEDULE}:${Math.floor(OVERRIDE_START.getTime() / 1000)}:P1`,
|
|
873
|
+
);
|
|
874
|
+
expect(variant.shiftKey).not.toBe(base.shiftKey);
|
|
875
|
+
expect(
|
|
876
|
+
variant.policies
|
|
877
|
+
.map((p: MaterializedShiftPolicy) => {
|
|
878
|
+
return p.policyId;
|
|
879
|
+
})
|
|
880
|
+
.sort(),
|
|
881
|
+
).toEqual(["P1", "P2"]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("filterShiftsForUser gives the substitute the variant and the original the base shift", async () => {
|
|
885
|
+
const { db, original } = await twoPolicyDb();
|
|
886
|
+
const result: MaterializeResult = await materializeSchedule(db);
|
|
887
|
+
|
|
888
|
+
const forE: Array<MaterializedShift> =
|
|
889
|
+
OnCallShiftMaterializer.filterShiftsForUser(result.shifts, oid("E"));
|
|
890
|
+
expect(forE).toHaveLength(1);
|
|
891
|
+
expect(forE[0]!.policyVariantOf?.policyId).toBe("P1");
|
|
892
|
+
|
|
893
|
+
const forOriginal: Array<MaterializedShift> =
|
|
894
|
+
OnCallShiftMaterializer.filterShiftsForUser(result.shifts, original);
|
|
895
|
+
expect(coveringShift(forOriginal, NOW)).toBeDefined();
|
|
896
|
+
expect(
|
|
897
|
+
forOriginal.every((s: MaterializedShift) => {
|
|
898
|
+
return s.userId === original;
|
|
899
|
+
}),
|
|
900
|
+
).toBe(true);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("cross-check: paging with P1 pages the substitute, paging with P2 pages the rostered user", async () => {
|
|
904
|
+
const { db } = await twoPolicyDb();
|
|
905
|
+
const result: MaterializeResult = await materializeSchedule(db);
|
|
906
|
+
|
|
907
|
+
await crossCheck({ shifts: result.shifts, now: NOW, policyId: "P1" });
|
|
908
|
+
await crossCheck({ shifts: result.shifts, now: NOW, policyId: "P2" });
|
|
909
|
+
|
|
910
|
+
jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(NOW);
|
|
911
|
+
const viaP1: ObjectID | null =
|
|
912
|
+
await OnCallDutyPolicyScheduleService.getCurrentUserIdInSchedule(
|
|
913
|
+
oid(SCHEDULE),
|
|
914
|
+
{ onCallDutyPolicyId: oid("P1") },
|
|
915
|
+
);
|
|
916
|
+
const viaP2: ObjectID | null =
|
|
917
|
+
await OnCallDutyPolicyScheduleService.getCurrentUserIdInSchedule(
|
|
918
|
+
oid(SCHEDULE),
|
|
919
|
+
{ onCallDutyPolicyId: oid("P2") },
|
|
920
|
+
);
|
|
921
|
+
expect(viaP1?.toString()).toBe("E");
|
|
922
|
+
expect(viaP2?.toString()).not.toBe("E");
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("the substitute's personal feed carries the variant even though they are not a member", async () => {
|
|
926
|
+
const { db } = await twoPolicyDb();
|
|
927
|
+
installFakeDb(db);
|
|
928
|
+
|
|
929
|
+
const result: MaterializeResult =
|
|
930
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
931
|
+
userId: oid("E"),
|
|
932
|
+
windowStart: WINDOW_START,
|
|
933
|
+
windowEnd: WINDOW_END,
|
|
934
|
+
now: NOW,
|
|
935
|
+
includeCoveringShifts: true,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
expect(result.shifts).toHaveLength(1);
|
|
939
|
+
expect(result.shifts[0]!.policyVariantOf?.policyId).toBe("P1");
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe("the audit seam fixture: A[10:00-11:00], B[11:00:01-12:00] over a 24x7 fallback", () => {
|
|
944
|
+
function seamDb(): FakeDb {
|
|
945
|
+
const db: FakeDb = emptyDb();
|
|
946
|
+
db.schedules.push(
|
|
947
|
+
makeSchedule({
|
|
948
|
+
id: SCHEDULE,
|
|
949
|
+
projectId: PROJECT,
|
|
950
|
+
name: "Seams",
|
|
951
|
+
timezone: UTC,
|
|
952
|
+
}),
|
|
953
|
+
);
|
|
954
|
+
db.layers.push(
|
|
955
|
+
makeLayer({
|
|
956
|
+
id: "layer-hourly",
|
|
957
|
+
scheduleId: SCHEDULE,
|
|
958
|
+
projectId: PROJECT,
|
|
959
|
+
name: "Hourly",
|
|
960
|
+
order: 1,
|
|
961
|
+
startsAt: at("2026-03-02T00:00:00Z"),
|
|
962
|
+
handOffTime: at("2026-03-02T11:00:00Z"),
|
|
963
|
+
rotation: rotation(EventInterval.Hour, 1),
|
|
964
|
+
restrictionTimes: dailyRestriction("10:00", "12:00", UTC),
|
|
965
|
+
}),
|
|
966
|
+
makeLayer({
|
|
967
|
+
id: "layer-24x7",
|
|
968
|
+
scheduleId: SCHEDULE,
|
|
969
|
+
projectId: PROJECT,
|
|
970
|
+
name: "Always",
|
|
971
|
+
order: 2,
|
|
972
|
+
startsAt: at("2026-03-02T00:00:00Z"),
|
|
973
|
+
handOffTime: at("2026-03-02T00:00:00Z"),
|
|
974
|
+
rotation: rotation(EventInterval.Week, 1),
|
|
975
|
+
}),
|
|
976
|
+
);
|
|
977
|
+
db.layerUsers.push(
|
|
978
|
+
makeLayerUser({
|
|
979
|
+
id: "lu-a",
|
|
980
|
+
scheduleId: SCHEDULE,
|
|
981
|
+
layerId: "layer-hourly",
|
|
982
|
+
projectId: PROJECT,
|
|
983
|
+
userId: "A",
|
|
984
|
+
order: 1,
|
|
985
|
+
}),
|
|
986
|
+
makeLayerUser({
|
|
987
|
+
id: "lu-b",
|
|
988
|
+
scheduleId: SCHEDULE,
|
|
989
|
+
layerId: "layer-hourly",
|
|
990
|
+
projectId: PROJECT,
|
|
991
|
+
userId: "B",
|
|
992
|
+
order: 2,
|
|
993
|
+
}),
|
|
994
|
+
makeLayerUser({
|
|
995
|
+
id: "lu-c",
|
|
996
|
+
scheduleId: SCHEDULE,
|
|
997
|
+
layerId: "layer-24x7",
|
|
998
|
+
projectId: PROJECT,
|
|
999
|
+
userId: "C",
|
|
1000
|
+
order: 1,
|
|
1001
|
+
}),
|
|
1002
|
+
);
|
|
1003
|
+
return db;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
test("the one-second seam disappears: the two hourly blocks touch exactly at 11:00:00", async () => {
|
|
1007
|
+
const result: MaterializeResult = await materializeSchedule(seamDb());
|
|
1008
|
+
|
|
1009
|
+
const day: Array<MaterializedShift> = baseShifts(result.shifts).filter(
|
|
1010
|
+
(shift: MaterializedShift) => {
|
|
1011
|
+
return shift.layerName === "Hourly" && shift.start.getUTCDate() === 3;
|
|
1012
|
+
},
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
expect(
|
|
1016
|
+
day.map((s: MaterializedShift) => {
|
|
1017
|
+
return [s.userId, s.start.toISOString(), s.end.toISOString()];
|
|
1018
|
+
}),
|
|
1019
|
+
).toEqual([
|
|
1020
|
+
["A", "2026-03-03T10:00:00.000Z", "2026-03-03T11:00:00.000Z"],
|
|
1021
|
+
["B", "2026-03-03T11:00:00.000Z", "2026-03-03T12:00:00.000Z"],
|
|
1022
|
+
]);
|
|
1023
|
+
|
|
1024
|
+
const around: MaterializedShift = coveringShift(
|
|
1025
|
+
result.shifts,
|
|
1026
|
+
at("2026-03-03T09:59:00Z"),
|
|
1027
|
+
)!;
|
|
1028
|
+
expect(around.userId).toBe("C");
|
|
1029
|
+
expect(around.end.toISOString()).toBe("2026-03-03T10:00:00.000Z");
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("cross-check at 11:30 pages B; at 12:30 pages C", async () => {
|
|
1033
|
+
const result: MaterializeResult = await materializeSchedule(seamDb());
|
|
1034
|
+
await crossCheck({
|
|
1035
|
+
shifts: result.shifts,
|
|
1036
|
+
now: at("2026-03-03T11:30:00Z"),
|
|
1037
|
+
});
|
|
1038
|
+
await crossCheck({
|
|
1039
|
+
shifts: result.shifts,
|
|
1040
|
+
now: at("2026-03-03T12:30:00Z"),
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
describe("toShifts / diffPolicyVariant (pure helpers)", () => {
|
|
1046
|
+
test("toShifts drops empty segments and normalises seams", () => {
|
|
1047
|
+
const shifts: Array<OnCallShift> = OnCallShiftMaterializer.toShifts([
|
|
1048
|
+
{
|
|
1049
|
+
id: 1,
|
|
1050
|
+
title: "A",
|
|
1051
|
+
start: at("2026-03-03T09:00:01Z"),
|
|
1052
|
+
end: at("2026-03-03T10:59:59Z"),
|
|
1053
|
+
allDay: false,
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
id: 2,
|
|
1057
|
+
title: "B",
|
|
1058
|
+
start: at("2026-03-03T11:00:00Z"),
|
|
1059
|
+
end: at("2026-03-03T11:00:00Z"),
|
|
1060
|
+
allDay: false,
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
id: 3,
|
|
1064
|
+
title: "B",
|
|
1065
|
+
start: at("2026-03-03T11:00:01Z"),
|
|
1066
|
+
end: at("2026-03-03T12:00:00Z"),
|
|
1067
|
+
allDay: false,
|
|
1068
|
+
},
|
|
1069
|
+
] as Array<CalendarEvent>);
|
|
1070
|
+
|
|
1071
|
+
expect(
|
|
1072
|
+
shifts.map((s: OnCallShift) => {
|
|
1073
|
+
return [s.userId, s.start.toISOString(), s.end.toISOString()];
|
|
1074
|
+
}),
|
|
1075
|
+
).toEqual([
|
|
1076
|
+
["A", "2026-03-03T09:00:00.000Z", "2026-03-03T11:00:00.000Z"],
|
|
1077
|
+
["B", "2026-03-03T11:00:00.000Z", "2026-03-03T12:00:00.000Z"],
|
|
1078
|
+
]);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("diffPolicyVariant keeps only intervals where a different person is on call", () => {
|
|
1082
|
+
const base: Array<OnCallShift> = [
|
|
1083
|
+
{
|
|
1084
|
+
userId: "A",
|
|
1085
|
+
start: at("2026-03-03T09:00:00Z"),
|
|
1086
|
+
end: at("2026-03-03T17:00:00Z"),
|
|
1087
|
+
coverageSeconds: 8 * 3600,
|
|
1088
|
+
},
|
|
1089
|
+
];
|
|
1090
|
+
const variant: Array<OnCallShift> = [
|
|
1091
|
+
{
|
|
1092
|
+
userId: "A",
|
|
1093
|
+
start: at("2026-03-03T09:00:00Z"),
|
|
1094
|
+
end: at("2026-03-03T12:00:00Z"),
|
|
1095
|
+
coverageSeconds: 3 * 3600,
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
userId: "B",
|
|
1099
|
+
start: at("2026-03-03T12:00:00Z"),
|
|
1100
|
+
end: at("2026-03-03T13:00:00Z"),
|
|
1101
|
+
coverageSeconds: 3600,
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
userId: "A",
|
|
1105
|
+
start: at("2026-03-03T13:00:00Z"),
|
|
1106
|
+
end: at("2026-03-03T17:00:00Z"),
|
|
1107
|
+
coverageSeconds: 4 * 3600,
|
|
1108
|
+
},
|
|
1109
|
+
// No base coverage at all here: kept, attributed to the variant itself.
|
|
1110
|
+
{
|
|
1111
|
+
userId: "Z",
|
|
1112
|
+
start: at("2026-03-03T18:00:00Z"),
|
|
1113
|
+
end: at("2026-03-03T19:00:00Z"),
|
|
1114
|
+
coverageSeconds: 3600,
|
|
1115
|
+
},
|
|
1116
|
+
];
|
|
1117
|
+
|
|
1118
|
+
const diff: Array<{ shift: OnCallShift; globalUserId: string }> =
|
|
1119
|
+
OnCallShiftMaterializer.diffPolicyVariant(base, variant);
|
|
1120
|
+
|
|
1121
|
+
expect(
|
|
1122
|
+
diff.map((d: { shift: OnCallShift; globalUserId: string }) => {
|
|
1123
|
+
return [d.shift.userId, d.globalUserId];
|
|
1124
|
+
}),
|
|
1125
|
+
).toEqual([
|
|
1126
|
+
["B", "A"],
|
|
1127
|
+
["Z", "Z"],
|
|
1128
|
+
]);
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
describe("materializeForProject / materializeForUser scoping", () => {
|
|
1133
|
+
function twoScheduleDb(): FakeDb {
|
|
1134
|
+
const db: FakeDb = multiLayerDb();
|
|
1135
|
+
db.schedules.push(
|
|
1136
|
+
makeSchedule({
|
|
1137
|
+
id: SCHEDULE_2,
|
|
1138
|
+
projectId: PROJECT,
|
|
1139
|
+
name: "Checkout",
|
|
1140
|
+
timezone: UTC,
|
|
1141
|
+
}),
|
|
1142
|
+
);
|
|
1143
|
+
db.layers.push(
|
|
1144
|
+
makeLayer({
|
|
1145
|
+
id: "layer-s2",
|
|
1146
|
+
scheduleId: SCHEDULE_2,
|
|
1147
|
+
projectId: PROJECT,
|
|
1148
|
+
name: "Solo",
|
|
1149
|
+
startsAt: at("2026-01-01T00:00:00Z"),
|
|
1150
|
+
handOffTime: at("2026-01-01T00:00:00Z"),
|
|
1151
|
+
rotation: rotation(EventInterval.Day, 1),
|
|
1152
|
+
}),
|
|
1153
|
+
);
|
|
1154
|
+
db.layerUsers.push(
|
|
1155
|
+
makeLayerUser({
|
|
1156
|
+
id: "lu-f",
|
|
1157
|
+
scheduleId: SCHEDULE_2,
|
|
1158
|
+
layerId: "layer-s2",
|
|
1159
|
+
projectId: PROJECT,
|
|
1160
|
+
userId: "F",
|
|
1161
|
+
}),
|
|
1162
|
+
);
|
|
1163
|
+
return db;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
test("materializeForProject covers every schedule in the project", async () => {
|
|
1167
|
+
installFakeDb(twoScheduleDb());
|
|
1168
|
+
|
|
1169
|
+
const result: MaterializeResult =
|
|
1170
|
+
await OnCallShiftMaterializer.materializeForProject({
|
|
1171
|
+
projectId: oid(PROJECT),
|
|
1172
|
+
windowStart: WINDOW_START,
|
|
1173
|
+
windowEnd: WINDOW_END,
|
|
1174
|
+
now: NOW,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
expect(
|
|
1178
|
+
result.schedules
|
|
1179
|
+
.map((s: MaterializedScheduleInfo) => {
|
|
1180
|
+
return s.scheduleId;
|
|
1181
|
+
})
|
|
1182
|
+
.sort(),
|
|
1183
|
+
).toEqual([SCHEDULE, SCHEDULE_2]);
|
|
1184
|
+
expect(
|
|
1185
|
+
result.shifts.some((s: MaterializedShift) => {
|
|
1186
|
+
return s.scheduleId === SCHEDULE;
|
|
1187
|
+
}),
|
|
1188
|
+
).toBe(true);
|
|
1189
|
+
expect(
|
|
1190
|
+
result.shifts.some((s: MaterializedShift) => {
|
|
1191
|
+
return s.scheduleId === SCHEDULE_2 && s.userId === "F";
|
|
1192
|
+
}),
|
|
1193
|
+
).toBe(true);
|
|
1194
|
+
|
|
1195
|
+
// Sorted by start across schedules.
|
|
1196
|
+
for (let i: number = 1; i < result.shifts.length; i++) {
|
|
1197
|
+
expect(result.shifts[i]!.start.getTime()).toBeGreaterThanOrEqual(
|
|
1198
|
+
result.shifts[i - 1]!.start.getTime(),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
test("materializeForUser: only the user's own shifts, from every schedule they are on", async () => {
|
|
1204
|
+
installFakeDb(twoScheduleDb());
|
|
1205
|
+
|
|
1206
|
+
const result: MaterializeResult =
|
|
1207
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
1208
|
+
userId: oid("A"),
|
|
1209
|
+
windowStart: WINDOW_START,
|
|
1210
|
+
windowEnd: WINDOW_END,
|
|
1211
|
+
now: NOW,
|
|
1212
|
+
includeCoveringShifts: true,
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
expect(result.shifts.length).toBeGreaterThan(0);
|
|
1216
|
+
expect(
|
|
1217
|
+
result.shifts.every((s: MaterializedShift) => {
|
|
1218
|
+
return s.userId === "A";
|
|
1219
|
+
}),
|
|
1220
|
+
).toBe(true);
|
|
1221
|
+
expect(
|
|
1222
|
+
result.schedules.map((s: MaterializedScheduleInfo) => {
|
|
1223
|
+
return s.scheduleId;
|
|
1224
|
+
}),
|
|
1225
|
+
).toEqual([SCHEDULE]);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test("materializeForUser honours the scheduleId filter", async () => {
|
|
1229
|
+
installFakeDb(twoScheduleDb());
|
|
1230
|
+
|
|
1231
|
+
const other: MaterializeResult =
|
|
1232
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
1233
|
+
userId: oid("A"),
|
|
1234
|
+
scheduleId: oid(SCHEDULE_2),
|
|
1235
|
+
windowStart: WINDOW_START,
|
|
1236
|
+
windowEnd: WINDOW_END,
|
|
1237
|
+
now: NOW,
|
|
1238
|
+
includeCoveringShifts: true,
|
|
1239
|
+
});
|
|
1240
|
+
expect(other.shifts).toEqual([]);
|
|
1241
|
+
|
|
1242
|
+
const own: MaterializeResult =
|
|
1243
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
1244
|
+
userId: oid("A"),
|
|
1245
|
+
scheduleId: oid(SCHEDULE),
|
|
1246
|
+
windowStart: WINDOW_START,
|
|
1247
|
+
windowEnd: WINDOW_END,
|
|
1248
|
+
now: NOW,
|
|
1249
|
+
includeCoveringShifts: true,
|
|
1250
|
+
});
|
|
1251
|
+
expect(own.shifts.length).toBeGreaterThan(0);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test("materializeForUser honours the projectIds filter", async () => {
|
|
1255
|
+
installFakeDb(twoScheduleDb());
|
|
1256
|
+
|
|
1257
|
+
const result: MaterializeResult =
|
|
1258
|
+
await OnCallShiftMaterializer.materializeForUser({
|
|
1259
|
+
userId: oid("A"),
|
|
1260
|
+
projectIds: [oid("some-other-project")],
|
|
1261
|
+
windowStart: WINDOW_START,
|
|
1262
|
+
windowEnd: WINDOW_END,
|
|
1263
|
+
now: NOW,
|
|
1264
|
+
includeCoveringShifts: true,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
expect(result.shifts).toEqual([]);
|
|
1268
|
+
expect(OnCallDutyPolicyUserOverrideService.findBy).toHaveBeenCalledTimes(
|
|
1269
|
+
1,
|
|
1270
|
+
);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("getCandidateScheduleIdsForUser: own schedules plus covered schedules, deduped", async () => {
|
|
1274
|
+
const db: FakeDb = twoScheduleDb();
|
|
1275
|
+
db.overrides.push(
|
|
1276
|
+
makeOverride({
|
|
1277
|
+
id: "ov-cover",
|
|
1278
|
+
projectId: PROJECT,
|
|
1279
|
+
overrideUserId: "F",
|
|
1280
|
+
routeAlertsToUserId: "A",
|
|
1281
|
+
startsAt: at("2026-03-03T00:00:00Z"),
|
|
1282
|
+
endsAt: at("2026-03-04T00:00:00Z"),
|
|
1283
|
+
}),
|
|
1284
|
+
// Outside the window: must not pull in schedule 2 on its own.
|
|
1285
|
+
makeOverride({
|
|
1286
|
+
id: "ov-far",
|
|
1287
|
+
projectId: PROJECT,
|
|
1288
|
+
overrideUserId: "F",
|
|
1289
|
+
routeAlertsToUserId: "B",
|
|
1290
|
+
startsAt: at("2026-06-01T00:00:00Z"),
|
|
1291
|
+
endsAt: at("2026-06-02T00:00:00Z"),
|
|
1292
|
+
}),
|
|
1293
|
+
);
|
|
1294
|
+
installFakeDb(db);
|
|
1295
|
+
|
|
1296
|
+
const forA: Array<ObjectID> =
|
|
1297
|
+
await OnCallShiftMaterializer.getCandidateScheduleIdsForUser({
|
|
1298
|
+
userId: oid("A"),
|
|
1299
|
+
windowStart: WINDOW_START,
|
|
1300
|
+
windowEnd: WINDOW_END,
|
|
1301
|
+
includeCoveringShifts: true,
|
|
1302
|
+
});
|
|
1303
|
+
expect(ids(forA).sort()).toEqual([SCHEDULE, SCHEDULE_2]);
|
|
1304
|
+
|
|
1305
|
+
const forB: Array<ObjectID> =
|
|
1306
|
+
await OnCallShiftMaterializer.getCandidateScheduleIdsForUser({
|
|
1307
|
+
userId: oid("B"),
|
|
1308
|
+
windowStart: WINDOW_START,
|
|
1309
|
+
windowEnd: WINDOW_END,
|
|
1310
|
+
includeCoveringShifts: true,
|
|
1311
|
+
});
|
|
1312
|
+
expect(ids(forB)).toEqual([SCHEDULE]);
|
|
1313
|
+
|
|
1314
|
+
const forAOwnOnly: Array<ObjectID> =
|
|
1315
|
+
await OnCallShiftMaterializer.getCandidateScheduleIdsForUser({
|
|
1316
|
+
userId: oid("A"),
|
|
1317
|
+
windowStart: WINDOW_START,
|
|
1318
|
+
windowEnd: WINDOW_END,
|
|
1319
|
+
includeCoveringShifts: false,
|
|
1320
|
+
});
|
|
1321
|
+
expect(ids(forAOwnOnly)).toEqual([SCHEDULE]);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
/*
|
|
1325
|
+
* The batched form the reminder sweep uses: the same answer for a whole
|
|
1326
|
+
* group of users in the same 2-3 queries it takes for one, which is what
|
|
1327
|
+
* keeps a five-minute tick from issuing 2-3 queries PER reminded user.
|
|
1328
|
+
*/
|
|
1329
|
+
test("getCandidateScheduleIdsForUsers: one batched lookup answers every user, with coverage kept per user", async () => {
|
|
1330
|
+
const db: FakeDb = twoScheduleDb();
|
|
1331
|
+
db.overrides.push(
|
|
1332
|
+
makeOverride({
|
|
1333
|
+
id: "ov-cover",
|
|
1334
|
+
projectId: PROJECT,
|
|
1335
|
+
overrideUserId: "F",
|
|
1336
|
+
routeAlertsToUserId: "A",
|
|
1337
|
+
startsAt: at("2026-03-03T00:00:00Z"),
|
|
1338
|
+
endsAt: at("2026-03-04T00:00:00Z"),
|
|
1339
|
+
}),
|
|
1340
|
+
);
|
|
1341
|
+
installFakeDb(db);
|
|
1342
|
+
|
|
1343
|
+
const byUser: Map<
|
|
1344
|
+
string,
|
|
1345
|
+
Array<ObjectID>
|
|
1346
|
+
> = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
|
|
1347
|
+
userIds: [oid("A"), oid("B"), oid("nobody")],
|
|
1348
|
+
windowStart: WINDOW_START,
|
|
1349
|
+
windowEnd: WINDOW_END,
|
|
1350
|
+
includeCoveringShifts: true,
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
// A is on schedule 1 and covers F, who is on schedule 2.
|
|
1354
|
+
expect(ids(byUser.get("A") || []).sort()).toEqual([SCHEDULE, SCHEDULE_2]);
|
|
1355
|
+
// B is on schedule 1 only: A's coverage never leaks into B's list.
|
|
1356
|
+
expect(ids(byUser.get("B") || [])).toEqual([SCHEDULE]);
|
|
1357
|
+
// Every requested user gets an entry, even with nothing to show.
|
|
1358
|
+
expect(byUser.has("nobody")).toBe(true);
|
|
1359
|
+
expect(byUser.get("nobody")).toEqual([]);
|
|
1360
|
+
|
|
1361
|
+
// Two layer-user queries and ONE override query for all three users.
|
|
1362
|
+
expect(OnCallDutyPolicyUserOverrideService.findBy).toHaveBeenCalledTimes(
|
|
1363
|
+
1,
|
|
1364
|
+
);
|
|
1365
|
+
expect(
|
|
1366
|
+
OnCallDutyPolicyScheduleLayerUserService.findBy,
|
|
1367
|
+
).toHaveBeenCalledTimes(2);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("getCandidateScheduleIdsForUsers: no users means no queries at all", async () => {
|
|
1371
|
+
installFakeDb(twoScheduleDb());
|
|
1372
|
+
|
|
1373
|
+
const byUser: Map<
|
|
1374
|
+
string,
|
|
1375
|
+
Array<ObjectID>
|
|
1376
|
+
> = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
|
|
1377
|
+
userIds: [],
|
|
1378
|
+
windowStart: WINDOW_START,
|
|
1379
|
+
windowEnd: WINDOW_END,
|
|
1380
|
+
includeCoveringShifts: true,
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
expect(byUser.size).toBe(0);
|
|
1384
|
+
expect(
|
|
1385
|
+
OnCallDutyPolicyScheduleLayerUserService.findBy,
|
|
1386
|
+
).toHaveBeenCalledTimes(0);
|
|
1387
|
+
expect(OnCallDutyPolicyUserOverrideService.findBy).toHaveBeenCalledTimes(
|
|
1388
|
+
0,
|
|
1389
|
+
);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("getCandidateScheduleIdsForUsers honours the scheduleId narrowing for every user", async () => {
|
|
1393
|
+
installFakeDb(twoScheduleDb());
|
|
1394
|
+
|
|
1395
|
+
const byUser: Map<
|
|
1396
|
+
string,
|
|
1397
|
+
Array<ObjectID>
|
|
1398
|
+
> = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
|
|
1399
|
+
userIds: [oid("A"), oid("F")],
|
|
1400
|
+
scheduleId: oid(SCHEDULE_2),
|
|
1401
|
+
windowStart: WINDOW_START,
|
|
1402
|
+
windowEnd: WINDOW_END,
|
|
1403
|
+
includeCoveringShifts: false,
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
expect(ids(byUser.get("A") || [])).toEqual([]);
|
|
1407
|
+
expect(ids(byUser.get("F") || [])).toEqual([SCHEDULE_2]);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test("an empty project yields an empty result", async () => {
|
|
1411
|
+
installFakeDb(emptyDb());
|
|
1412
|
+
|
|
1413
|
+
const result: MaterializeResult =
|
|
1414
|
+
await OnCallShiftMaterializer.materializeForProject({
|
|
1415
|
+
projectId: oid("empty-project"),
|
|
1416
|
+
windowStart: WINDOW_START,
|
|
1417
|
+
windowEnd: WINDOW_END,
|
|
1418
|
+
now: NOW,
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
expect(result).toEqual({
|
|
1422
|
+
shifts: [],
|
|
1423
|
+
truncated: false,
|
|
1424
|
+
schedules: [],
|
|
1425
|
+
users: [],
|
|
1426
|
+
generatedAt: NOW,
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
describe("truncation and validation", () => {
|
|
1432
|
+
test("propagates truncated from the resolver", async () => {
|
|
1433
|
+
const db: FakeDb = emptyDb();
|
|
1434
|
+
db.schedules.push(
|
|
1435
|
+
makeSchedule({ id: SCHEDULE, projectId: PROJECT, timezone: UTC }),
|
|
1436
|
+
);
|
|
1437
|
+
db.layers.push(
|
|
1438
|
+
makeLayer({
|
|
1439
|
+
id: "layer-old",
|
|
1440
|
+
scheduleId: SCHEDULE,
|
|
1441
|
+
projectId: PROJECT,
|
|
1442
|
+
startsAt: at("2020-01-01T00:00:00Z"),
|
|
1443
|
+
handOffTime: at("2020-01-01T09:00:00Z"),
|
|
1444
|
+
rotation: rotation(EventInterval.Hour, 1),
|
|
1445
|
+
restrictionTimes: dailyRestriction("09:00", "17:00", UTC),
|
|
1446
|
+
}),
|
|
1447
|
+
);
|
|
1448
|
+
db.layerUsers.push(
|
|
1449
|
+
makeLayerUser({
|
|
1450
|
+
id: "lu-q",
|
|
1451
|
+
scheduleId: SCHEDULE,
|
|
1452
|
+
layerId: "layer-old",
|
|
1453
|
+
projectId: PROJECT,
|
|
1454
|
+
userId: "Q",
|
|
1455
|
+
}),
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
const result: MaterializeResult = await materializeSchedule(db, {
|
|
1459
|
+
maxSimulationIterations: 10,
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
expect(result.truncated).toBe(true);
|
|
1463
|
+
expect(result.schedules[0]!.truncated).toBe(true);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
test("rejects an inverted or invalid window", async () => {
|
|
1467
|
+
installFakeDb(multiLayerDb());
|
|
1468
|
+
|
|
1469
|
+
await expect(
|
|
1470
|
+
OnCallShiftMaterializer.materializeForSchedule({
|
|
1471
|
+
scheduleId: oid(SCHEDULE),
|
|
1472
|
+
windowStart: WINDOW_END,
|
|
1473
|
+
windowEnd: WINDOW_START,
|
|
1474
|
+
}),
|
|
1475
|
+
).rejects.toBeInstanceOf(BadDataException);
|
|
1476
|
+
|
|
1477
|
+
await expect(
|
|
1478
|
+
OnCallShiftMaterializer.materializeForUser({
|
|
1479
|
+
userId: oid("A"),
|
|
1480
|
+
windowStart: new Date("nonsense"),
|
|
1481
|
+
windowEnd: WINDOW_END,
|
|
1482
|
+
includeCoveringShifts: true,
|
|
1483
|
+
}),
|
|
1484
|
+
).rejects.toBeInstanceOf(BadDataException);
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
test("unknown schedules materialize to an empty result", async () => {
|
|
1488
|
+
installFakeDb(multiLayerDb());
|
|
1489
|
+
|
|
1490
|
+
const result: MaterializeResult =
|
|
1491
|
+
await OnCallShiftMaterializer.materializeForSchedules({
|
|
1492
|
+
scheduleIds: [oid("missing")],
|
|
1493
|
+
windowStart: WINDOW_START,
|
|
1494
|
+
windowEnd: WINDOW_END,
|
|
1495
|
+
now: NOW,
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
expect(result.shifts).toEqual([]);
|
|
1499
|
+
expect(result.schedules).toEqual([]);
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
});
|