@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,1270 @@
|
|
|
1
|
+
import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
|
|
2
|
+
import OnCallDutyPolicyScheduleLayerService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerService";
|
|
3
|
+
import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
|
|
4
|
+
import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
|
|
5
|
+
import OnCallDutyPolicyEscalationRuleScheduleService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleScheduleService";
|
|
6
|
+
import OnCallDutyPolicyFeedService from "../../../Server/Services/OnCallDutyPolicyFeedService";
|
|
7
|
+
import OnCallDutyPolicyTimeLogService from "../../../Server/Services/OnCallDutyPolicyTimeLogService";
|
|
8
|
+
import OnCallCalendarFeedCache from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
|
|
9
|
+
import Semaphore from "../../../Server/Infrastructure/Semaphore";
|
|
10
|
+
import OnCallShiftChangeListeners, {
|
|
11
|
+
OnCallShiftChangeEvent,
|
|
12
|
+
OnCallShiftChangeReason,
|
|
13
|
+
} from "../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
|
|
14
|
+
import logger from "../../../Server/Utils/Logger";
|
|
15
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
16
|
+
import {
|
|
17
|
+
afterEach,
|
|
18
|
+
beforeEach,
|
|
19
|
+
describe,
|
|
20
|
+
expect,
|
|
21
|
+
jest,
|
|
22
|
+
test,
|
|
23
|
+
} from "@jest/globals";
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Change propagation for the on-call calendar feeds:
|
|
27
|
+
*
|
|
28
|
+
* - bumpShiftConfigVersion: one atomic increment per distinct schedule,
|
|
29
|
+
* best-effort;
|
|
30
|
+
* - propagateShiftConfigChange: bump + cache purges + listener event;
|
|
31
|
+
* - every configuration hook (layer, layer user, override, policy
|
|
32
|
+
* attachment, schedule rename/timezone, schedule delete) calls it AFTER
|
|
33
|
+
* the roster refresh, with the right schedules, users and reason;
|
|
34
|
+
* - the roster refresh itself NEVER bumps the version.
|
|
35
|
+
*
|
|
36
|
+
* The hooks are protected, so they are invoked through `as any` — the same
|
|
37
|
+
* discipline as OnCallScheduleAttachRosterRefresh.test.ts.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const PROJECT_ID: ObjectID = new ObjectID("project-1");
|
|
41
|
+
const SCHEDULE_1: ObjectID = new ObjectID("schedule-1");
|
|
42
|
+
const SCHEDULE_2: ObjectID = new ObjectID("schedule-2");
|
|
43
|
+
const LAYER_1: ObjectID = new ObjectID("layer-1");
|
|
44
|
+
const USER_A: ObjectID = new ObjectID("user-a");
|
|
45
|
+
const USER_B: ObjectID = new ObjectID("user-b");
|
|
46
|
+
const USER_C: ObjectID = new ObjectID("user-c");
|
|
47
|
+
const USER_D: ObjectID = new ObjectID("user-d");
|
|
48
|
+
const ROW_ID: ObjectID = new ObjectID("row-1");
|
|
49
|
+
const POLICY_ID: ObjectID = new ObjectID("policy-1");
|
|
50
|
+
const RULE_ID: ObjectID = new ObjectID("rule-1");
|
|
51
|
+
|
|
52
|
+
const ROSTER_RESULT: {
|
|
53
|
+
currentUserId: null;
|
|
54
|
+
handOffTimeAt: null;
|
|
55
|
+
nextUserId: null;
|
|
56
|
+
nextHandOffTimeAt: null;
|
|
57
|
+
rosterStartAt: null;
|
|
58
|
+
nextRosterStartAt: null;
|
|
59
|
+
} = {
|
|
60
|
+
currentUserId: null,
|
|
61
|
+
handOffTimeAt: null,
|
|
62
|
+
nextUserId: null,
|
|
63
|
+
nextHandOffTimeAt: null,
|
|
64
|
+
rosterStartAt: null,
|
|
65
|
+
nextRosterStartAt: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function ids(list: Array<ObjectID>): Array<string> {
|
|
69
|
+
return list.map((id: ObjectID) => {
|
|
70
|
+
return id.toString();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function silenceLogger(): void {
|
|
75
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
78
|
+
jest.spyOn(logger, "debug").mockImplementation((): void => {
|
|
79
|
+
return undefined;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function spyRosterRefresh(): any {
|
|
84
|
+
return jest
|
|
85
|
+
.spyOn(
|
|
86
|
+
OnCallDutyPolicyScheduleService,
|
|
87
|
+
"refreshCurrentUserIdAndHandoffTimeInSchedule",
|
|
88
|
+
)
|
|
89
|
+
.mockResolvedValue(ROSTER_RESULT);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function spyPropagate(): any {
|
|
93
|
+
return jest
|
|
94
|
+
.spyOn(OnCallDutyPolicyScheduleService, "propagateShiftConfigChange")
|
|
95
|
+
.mockResolvedValue(undefined);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function lastCallArg(spy: any): any {
|
|
99
|
+
return spy.mock.calls[spy.mock.calls.length - 1]![0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectCalledAfter(later: any, earlier: any): void {
|
|
103
|
+
const earlierOrder: number = earlier.mock.invocationCallOrder[0]!;
|
|
104
|
+
const laterOrder: number = later.mock.invocationCallOrder[0]!;
|
|
105
|
+
expect(laterOrder).toBeGreaterThan(earlierOrder);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("bumpShiftConfigVersion", () => {
|
|
109
|
+
beforeEach(silenceLogger);
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
jest.restoreAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("adds one to shiftConfigVersion of each DISTINCT schedule with a single atomic statement", async () => {
|
|
115
|
+
const atomic: any = jest
|
|
116
|
+
.spyOn(
|
|
117
|
+
OnCallDutyPolicyScheduleService,
|
|
118
|
+
"atomicAddToColumnsByIdWithoutHooks",
|
|
119
|
+
)
|
|
120
|
+
.mockResolvedValue(undefined);
|
|
121
|
+
|
|
122
|
+
await OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([
|
|
123
|
+
SCHEDULE_1,
|
|
124
|
+
SCHEDULE_2,
|
|
125
|
+
new ObjectID("schedule-1"),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
expect(atomic).toHaveBeenCalledTimes(2);
|
|
129
|
+
expect(atomic.mock.calls[0]![0]).toEqual({
|
|
130
|
+
id: SCHEDULE_1,
|
|
131
|
+
add: { shiftConfigVersion: 1 },
|
|
132
|
+
});
|
|
133
|
+
expect(atomic.mock.calls[1]![0].id.toString()).toBe("schedule-2");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("is best-effort: one failing schedule is logged and the rest are still bumped", async () => {
|
|
137
|
+
const atomic: any = jest
|
|
138
|
+
.spyOn(
|
|
139
|
+
OnCallDutyPolicyScheduleService,
|
|
140
|
+
"atomicAddToColumnsByIdWithoutHooks",
|
|
141
|
+
)
|
|
142
|
+
.mockImplementation((input: any): Promise<void> => {
|
|
143
|
+
if (input.id.toString() === "schedule-1") {
|
|
144
|
+
return Promise.reject(new Error("db down"));
|
|
145
|
+
}
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([
|
|
151
|
+
SCHEDULE_1,
|
|
152
|
+
SCHEDULE_2,
|
|
153
|
+
]),
|
|
154
|
+
).resolves.toBeUndefined();
|
|
155
|
+
|
|
156
|
+
expect(atomic).toHaveBeenCalledTimes(2);
|
|
157
|
+
expect(logger.error).toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("does nothing for an empty list", async () => {
|
|
161
|
+
const atomic: any = jest
|
|
162
|
+
.spyOn(
|
|
163
|
+
OnCallDutyPolicyScheduleService,
|
|
164
|
+
"atomicAddToColumnsByIdWithoutHooks",
|
|
165
|
+
)
|
|
166
|
+
.mockResolvedValue(undefined);
|
|
167
|
+
|
|
168
|
+
await OnCallDutyPolicyScheduleService.bumpShiftConfigVersion([]);
|
|
169
|
+
expect(atomic).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("propagateShiftConfigChange", () => {
|
|
174
|
+
let bump: any;
|
|
175
|
+
let purgeSchedule: any;
|
|
176
|
+
let purgeUser: any;
|
|
177
|
+
let notify: any;
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
silenceLogger();
|
|
181
|
+
bump = jest
|
|
182
|
+
.spyOn(OnCallDutyPolicyScheduleService, "bumpShiftConfigVersion")
|
|
183
|
+
.mockResolvedValue(undefined);
|
|
184
|
+
purgeSchedule = jest
|
|
185
|
+
.spyOn(OnCallCalendarFeedCache, "purgeForSchedule")
|
|
186
|
+
.mockResolvedValue(undefined);
|
|
187
|
+
purgeUser = jest
|
|
188
|
+
.spyOn(OnCallCalendarFeedCache, "purgeForUser")
|
|
189
|
+
.mockResolvedValue(undefined);
|
|
190
|
+
notify = jest
|
|
191
|
+
.spyOn(OnCallShiftChangeListeners, "notify")
|
|
192
|
+
.mockResolvedValue(undefined);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
jest.restoreAllMocks();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("bumps, purges the schedule caches and the named users' caches, and notifies with schedule members added", async () => {
|
|
200
|
+
jest
|
|
201
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
202
|
+
.mockResolvedValue([
|
|
203
|
+
{ userId: USER_B },
|
|
204
|
+
{ userId: USER_C },
|
|
205
|
+
{ userId: USER_A },
|
|
206
|
+
] as never);
|
|
207
|
+
|
|
208
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
209
|
+
scheduleIds: [SCHEDULE_1, SCHEDULE_1, SCHEDULE_2],
|
|
210
|
+
projectId: PROJECT_ID,
|
|
211
|
+
userIds: [USER_A],
|
|
212
|
+
reason: OnCallShiftChangeReason.LayerUserChanged,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(ids(bump.mock.calls[0]![0])).toEqual(["schedule-1", "schedule-2"]);
|
|
216
|
+
|
|
217
|
+
expect(
|
|
218
|
+
purgeSchedule.mock.calls.map((c: Array<unknown>) => {
|
|
219
|
+
return c[0];
|
|
220
|
+
}),
|
|
221
|
+
).toEqual(["schedule-1", "schedule-2"]);
|
|
222
|
+
expect(purgeUser).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(purgeUser).toHaveBeenCalledWith("project-1", "user-a");
|
|
224
|
+
|
|
225
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
226
|
+
const event: OnCallShiftChangeEvent = lastCallArg(notify);
|
|
227
|
+
expect(event.projectId).toBe(PROJECT_ID);
|
|
228
|
+
expect(ids(event.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
|
|
229
|
+
// Explicit users first, then the members, deduped.
|
|
230
|
+
expect(ids(event.userIds)).toEqual(["user-a", "user-b", "user-c"]);
|
|
231
|
+
expect(event.reason).toBe(OnCallShiftChangeReason.LayerUserChanged);
|
|
232
|
+
expect(event.occurredAt).toBeInstanceOf(Date);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("skipVersionBump skips the bump but still purges and notifies", async () => {
|
|
236
|
+
jest
|
|
237
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
238
|
+
.mockResolvedValue([] as never);
|
|
239
|
+
|
|
240
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
241
|
+
scheduleIds: [SCHEDULE_1],
|
|
242
|
+
projectId: PROJECT_ID,
|
|
243
|
+
userIds: [USER_A],
|
|
244
|
+
reason: OnCallShiftChangeReason.ScheduleDeleted,
|
|
245
|
+
skipVersionBump: true,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(bump).not.toHaveBeenCalled();
|
|
249
|
+
expect(purgeSchedule).toHaveBeenCalledWith("schedule-1");
|
|
250
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(lastCallArg(notify).reason).toBe(
|
|
252
|
+
OnCallShiftChangeReason.ScheduleDeleted,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("with no schedules: no bump, no schedule purge, but user purge and a notify for the users", async () => {
|
|
257
|
+
const findBy: any = jest.spyOn(
|
|
258
|
+
OnCallDutyPolicyScheduleLayerUserService,
|
|
259
|
+
"findBy",
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
263
|
+
scheduleIds: [],
|
|
264
|
+
projectId: PROJECT_ID,
|
|
265
|
+
userIds: [USER_A, USER_B],
|
|
266
|
+
reason: OnCallShiftChangeReason.OverrideChanged,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(bump).not.toHaveBeenCalled();
|
|
270
|
+
expect(purgeSchedule).not.toHaveBeenCalled();
|
|
271
|
+
expect(findBy).not.toHaveBeenCalled();
|
|
272
|
+
expect(purgeUser).toHaveBeenCalledTimes(2);
|
|
273
|
+
expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a", "user-b"]);
|
|
274
|
+
expect(lastCallArg(notify).scheduleIds).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("without a project id the user purge is skipped and the event carries null", async () => {
|
|
278
|
+
jest
|
|
279
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
280
|
+
.mockResolvedValue([] as never);
|
|
281
|
+
|
|
282
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
283
|
+
scheduleIds: [SCHEDULE_1],
|
|
284
|
+
userIds: [USER_A],
|
|
285
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(purgeUser).not.toHaveBeenCalled();
|
|
289
|
+
expect(lastCallArg(notify).projectId).toBeNull();
|
|
290
|
+
expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a"]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a failing member lookup still notifies with the explicit users", async () => {
|
|
294
|
+
jest
|
|
295
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
296
|
+
.mockRejectedValue(new Error("db down"));
|
|
297
|
+
|
|
298
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
299
|
+
scheduleIds: [SCHEDULE_1],
|
|
300
|
+
projectId: PROJECT_ID,
|
|
301
|
+
userIds: [USER_A],
|
|
302
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(ids(lastCallArg(notify).userIds)).toEqual(["user-a"]);
|
|
307
|
+
expect(logger.error).toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("failing purges never throw and do not stop the notification", async () => {
|
|
311
|
+
purgeSchedule.mockRejectedValue(new Error("redis down"));
|
|
312
|
+
purgeUser.mockRejectedValue(new Error("redis down"));
|
|
313
|
+
jest
|
|
314
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
315
|
+
.mockResolvedValue([] as never);
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
319
|
+
scheduleIds: [SCHEDULE_1],
|
|
320
|
+
projectId: PROJECT_ID,
|
|
321
|
+
userIds: [USER_A],
|
|
322
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
323
|
+
}),
|
|
324
|
+
).resolves.toBeUndefined();
|
|
325
|
+
|
|
326
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a throwing bump never propagates", async () => {
|
|
330
|
+
bump.mockRejectedValue(new Error("unexpected"));
|
|
331
|
+
jest
|
|
332
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
333
|
+
.mockResolvedValue([] as never);
|
|
334
|
+
|
|
335
|
+
await expect(
|
|
336
|
+
OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
337
|
+
scheduleIds: [SCHEDULE_1],
|
|
338
|
+
projectId: PROJECT_ID,
|
|
339
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
340
|
+
}),
|
|
341
|
+
).resolves.toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("delivers the event to a REAL registered listener without awaiting it", async () => {
|
|
345
|
+
notify.mockRestore();
|
|
346
|
+
OnCallShiftChangeListeners.clear();
|
|
347
|
+
|
|
348
|
+
let resolveListener: () => void = (): void => {
|
|
349
|
+
return undefined;
|
|
350
|
+
};
|
|
351
|
+
const received: Array<OnCallShiftChangeEvent> = [];
|
|
352
|
+
|
|
353
|
+
OnCallShiftChangeListeners.register(
|
|
354
|
+
(event: OnCallShiftChangeEvent): Promise<void> => {
|
|
355
|
+
received.push(event);
|
|
356
|
+
return new Promise<void>((resolve: () => void) => {
|
|
357
|
+
resolveListener = resolve;
|
|
358
|
+
});
|
|
359
|
+
},
|
|
360
|
+
"slow-listener",
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
jest
|
|
364
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
365
|
+
.mockResolvedValue([] as never);
|
|
366
|
+
|
|
367
|
+
// Resolves although the listener has not: delivery is in the background.
|
|
368
|
+
await OnCallDutyPolicyScheduleService.propagateShiftConfigChange({
|
|
369
|
+
scheduleIds: [SCHEDULE_1],
|
|
370
|
+
projectId: PROJECT_ID,
|
|
371
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(received).toHaveLength(1);
|
|
375
|
+
expect(ids(received[0]!.scheduleIds)).toEqual(["schedule-1"]);
|
|
376
|
+
|
|
377
|
+
resolveListener();
|
|
378
|
+
OnCallShiftChangeListeners.clear();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("getScheduleIdsForUsersInProject", () => {
|
|
383
|
+
beforeEach(silenceLogger);
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
jest.restoreAllMocks();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("returns the distinct schedules of the users' layer-user rows", async () => {
|
|
389
|
+
const findBy: any = jest
|
|
390
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
391
|
+
.mockResolvedValue([
|
|
392
|
+
{ onCallDutyPolicyScheduleId: SCHEDULE_1 },
|
|
393
|
+
{ onCallDutyPolicyScheduleId: SCHEDULE_2 },
|
|
394
|
+
{ onCallDutyPolicyScheduleId: new ObjectID("schedule-1") },
|
|
395
|
+
{},
|
|
396
|
+
] as never);
|
|
397
|
+
|
|
398
|
+
const result: Array<ObjectID> =
|
|
399
|
+
await OnCallDutyPolicyScheduleService.getScheduleIdsForUsersInProject({
|
|
400
|
+
projectId: PROJECT_ID,
|
|
401
|
+
userIds: [USER_A, USER_B, USER_A],
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(ids(result)).toEqual(["schedule-1", "schedule-2"]);
|
|
405
|
+
expect(findBy).toHaveBeenCalledTimes(1);
|
|
406
|
+
const query: any = findBy.mock.calls[0]![0].query;
|
|
407
|
+
expect(query.projectId).toBe(PROJECT_ID);
|
|
408
|
+
expect(query.userId).toBeDefined();
|
|
409
|
+
expect(findBy.mock.calls[0]![0].props.isRoot).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("does not query for an empty user list", async () => {
|
|
413
|
+
const findBy: any = jest.spyOn(
|
|
414
|
+
OnCallDutyPolicyScheduleLayerUserService,
|
|
415
|
+
"findBy",
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const result: Array<ObjectID> =
|
|
419
|
+
await OnCallDutyPolicyScheduleService.getScheduleIdsForUsersInProject({
|
|
420
|
+
projectId: PROJECT_ID,
|
|
421
|
+
userIds: [],
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
expect(result).toEqual([]);
|
|
425
|
+
expect(findBy).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("the roster refresh never bumps shiftConfigVersion", () => {
|
|
430
|
+
beforeEach(silenceLogger);
|
|
431
|
+
afterEach(() => {
|
|
432
|
+
jest.restoreAllMocks();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("refreshCurrentUserIdAndHandoffTimeInSchedule persists the roster without touching the version or the listeners", async () => {
|
|
436
|
+
jest.spyOn(Semaphore, "lock").mockRejectedValue(new Error("no redis"));
|
|
437
|
+
jest.spyOn(Semaphore, "release").mockResolvedValue(undefined as never);
|
|
438
|
+
|
|
439
|
+
jest
|
|
440
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findOneById")
|
|
441
|
+
.mockResolvedValue({
|
|
442
|
+
currentUserIdOnRoster: USER_A,
|
|
443
|
+
rosterHandoffAt: null,
|
|
444
|
+
nextUserIdOnRoster: null,
|
|
445
|
+
rosterNextHandoffAt: null,
|
|
446
|
+
rosterStartAt: null,
|
|
447
|
+
rosterNextStartAt: null,
|
|
448
|
+
} as never);
|
|
449
|
+
|
|
450
|
+
jest
|
|
451
|
+
.spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findBy")
|
|
452
|
+
.mockResolvedValue([] as never);
|
|
453
|
+
|
|
454
|
+
jest
|
|
455
|
+
.spyOn(
|
|
456
|
+
OnCallDutyPolicyScheduleService,
|
|
457
|
+
"getCurrrentUserIdAndHandoffTimeInSchedule",
|
|
458
|
+
)
|
|
459
|
+
.mockResolvedValue({
|
|
460
|
+
currentUserId: USER_B,
|
|
461
|
+
handOffTimeAt: new Date("2026-09-01T09:00:00Z"),
|
|
462
|
+
nextUserId: USER_A,
|
|
463
|
+
nextHandOffTimeAt: new Date("2026-09-02T09:00:00Z"),
|
|
464
|
+
rosterStartAt: new Date("2026-08-31T09:00:00Z"),
|
|
465
|
+
nextRosterStartAt: new Date("2026-09-01T09:00:00Z"),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const updateOneById: any = jest
|
|
469
|
+
.spyOn(OnCallDutyPolicyScheduleService, "updateOneById")
|
|
470
|
+
.mockResolvedValue(undefined as never);
|
|
471
|
+
|
|
472
|
+
const bump: any = jest
|
|
473
|
+
.spyOn(OnCallDutyPolicyScheduleService, "bumpShiftConfigVersion")
|
|
474
|
+
.mockResolvedValue(undefined);
|
|
475
|
+
const atomic: any = jest
|
|
476
|
+
.spyOn(
|
|
477
|
+
OnCallDutyPolicyScheduleService,
|
|
478
|
+
"atomicAddToColumnsByIdWithoutHooks",
|
|
479
|
+
)
|
|
480
|
+
.mockResolvedValue(undefined);
|
|
481
|
+
const propagate: any = spyPropagate();
|
|
482
|
+
const notify: any = jest
|
|
483
|
+
.spyOn(OnCallShiftChangeListeners, "notify")
|
|
484
|
+
.mockResolvedValue(undefined);
|
|
485
|
+
|
|
486
|
+
await OnCallDutyPolicyScheduleService.refreshCurrentUserIdAndHandoffTimeInSchedule(
|
|
487
|
+
SCHEDULE_1,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// The roster was persisted through the hook-free path...
|
|
491
|
+
expect(updateOneById).toHaveBeenCalledTimes(1);
|
|
492
|
+
expect(updateOneById.mock.calls[0]![0].props).toEqual({
|
|
493
|
+
isRoot: true,
|
|
494
|
+
ignoreHooks: true,
|
|
495
|
+
});
|
|
496
|
+
expect(
|
|
497
|
+
updateOneById.mock.calls[0]![0].data.currentUserIdOnRoster.toString(),
|
|
498
|
+
).toBe("user-b");
|
|
499
|
+
|
|
500
|
+
// ...and nothing that a calendar client would see as an edit happened.
|
|
501
|
+
expect(bump).not.toHaveBeenCalled();
|
|
502
|
+
expect(atomic).not.toHaveBeenCalled();
|
|
503
|
+
expect(propagate).not.toHaveBeenCalled();
|
|
504
|
+
expect(notify).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("onUpdateSuccess ignores roster-column writes and only reacts to name / timezone", async () => {
|
|
508
|
+
const propagate: any = spyPropagate();
|
|
509
|
+
const findBy: any = jest
|
|
510
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findBy")
|
|
511
|
+
.mockResolvedValue([{ id: SCHEDULE_1, projectId: PROJECT_ID }] as never);
|
|
512
|
+
|
|
513
|
+
await (OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
|
|
514
|
+
{
|
|
515
|
+
updateBy: {
|
|
516
|
+
data: { currentUserIdOnRoster: USER_A, rosterHandoffAt: new Date() },
|
|
517
|
+
query: { _id: SCHEDULE_1 },
|
|
518
|
+
props: { isRoot: true },
|
|
519
|
+
},
|
|
520
|
+
carryForward: null,
|
|
521
|
+
},
|
|
522
|
+
[SCHEDULE_1],
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(findBy).not.toHaveBeenCalled();
|
|
526
|
+
expect(propagate).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("hook wiring: OnCallDutyPolicyScheduleService (rename / timezone / delete)", () => {
|
|
531
|
+
beforeEach(silenceLogger);
|
|
532
|
+
afterEach(() => {
|
|
533
|
+
jest.restoreAllMocks();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a rename bumps every updated schedule, grouped by project", async () => {
|
|
537
|
+
const propagate: any = spyPropagate();
|
|
538
|
+
jest.spyOn(OnCallDutyPolicyScheduleService, "findBy").mockResolvedValue([
|
|
539
|
+
{ id: SCHEDULE_1, projectId: PROJECT_ID },
|
|
540
|
+
{ id: SCHEDULE_2, projectId: new ObjectID("project-2") },
|
|
541
|
+
] as never);
|
|
542
|
+
|
|
543
|
+
const onUpdate: any = {
|
|
544
|
+
updateBy: {
|
|
545
|
+
data: { name: "Renamed" },
|
|
546
|
+
query: { _id: SCHEDULE_1 },
|
|
547
|
+
props: {},
|
|
548
|
+
},
|
|
549
|
+
carryForward: null,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const returned: any = await (
|
|
553
|
+
OnCallDutyPolicyScheduleService as any
|
|
554
|
+
).onUpdateSuccess(onUpdate, [SCHEDULE_1, SCHEDULE_2]);
|
|
555
|
+
|
|
556
|
+
expect(returned).toBe(onUpdate);
|
|
557
|
+
expect(propagate).toHaveBeenCalledTimes(2);
|
|
558
|
+
|
|
559
|
+
const byProject: Record<string, Array<string>> = {};
|
|
560
|
+
for (const call of propagate.mock.calls) {
|
|
561
|
+
const arg: any = call[0];
|
|
562
|
+
byProject[arg.projectId.toString()] = ids(arg.scheduleIds);
|
|
563
|
+
expect(arg.reason).toBe(OnCallShiftChangeReason.ScheduleChanged);
|
|
564
|
+
}
|
|
565
|
+
expect(byProject).toEqual({
|
|
566
|
+
"project-1": ["schedule-1"],
|
|
567
|
+
"project-2": ["schedule-2"],
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("a timezone change bumps as well", async () => {
|
|
572
|
+
const propagate: any = spyPropagate();
|
|
573
|
+
jest
|
|
574
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findBy")
|
|
575
|
+
.mockResolvedValue([{ id: SCHEDULE_1, projectId: PROJECT_ID }] as never);
|
|
576
|
+
|
|
577
|
+
await (OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
|
|
578
|
+
{
|
|
579
|
+
updateBy: {
|
|
580
|
+
data: { timezone: "Europe/Stockholm" },
|
|
581
|
+
query: {},
|
|
582
|
+
props: {},
|
|
583
|
+
},
|
|
584
|
+
carryForward: null,
|
|
585
|
+
},
|
|
586
|
+
[SCHEDULE_1],
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("a failing lookup in onUpdateSuccess is logged, not thrown", async () => {
|
|
593
|
+
jest
|
|
594
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findBy")
|
|
595
|
+
.mockRejectedValue(new Error("db down"));
|
|
596
|
+
|
|
597
|
+
await expect(
|
|
598
|
+
(OnCallDutyPolicyScheduleService as any).onUpdateSuccess(
|
|
599
|
+
{
|
|
600
|
+
updateBy: { data: { name: "x" }, query: {}, props: {} },
|
|
601
|
+
carryForward: null,
|
|
602
|
+
},
|
|
603
|
+
[SCHEDULE_1],
|
|
604
|
+
),
|
|
605
|
+
).resolves.toBeDefined();
|
|
606
|
+
expect(logger.error).toHaveBeenCalled();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("onBeforeDelete captures each schedule's members; onDeleteSuccess purges without a version bump", async () => {
|
|
610
|
+
jest.spyOn(OnCallDutyPolicyScheduleService, "findBy").mockResolvedValue([
|
|
611
|
+
{ id: SCHEDULE_1, _id: SCHEDULE_1, projectId: PROJECT_ID },
|
|
612
|
+
{ id: SCHEDULE_2, _id: SCHEDULE_2, projectId: PROJECT_ID },
|
|
613
|
+
] as never);
|
|
614
|
+
jest
|
|
615
|
+
.spyOn(OnCallDutyPolicyTimeLogService, "endTimeForSchedule")
|
|
616
|
+
.mockResolvedValue(undefined as never);
|
|
617
|
+
jest
|
|
618
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
619
|
+
.mockResolvedValue([
|
|
620
|
+
{ userId: USER_A, onCallDutyPolicyScheduleId: SCHEDULE_1 },
|
|
621
|
+
{ userId: USER_B, onCallDutyPolicyScheduleId: SCHEDULE_1 },
|
|
622
|
+
{ userId: USER_A, onCallDutyPolicyScheduleId: SCHEDULE_1 },
|
|
623
|
+
{ userId: USER_C, onCallDutyPolicyScheduleId: SCHEDULE_2 },
|
|
624
|
+
] as never);
|
|
625
|
+
|
|
626
|
+
const onDelete: any = await (
|
|
627
|
+
OnCallDutyPolicyScheduleService as any
|
|
628
|
+
).onBeforeDelete({
|
|
629
|
+
query: { projectId: PROJECT_ID },
|
|
630
|
+
props: { isRoot: true },
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const deleted: Array<any> = onDelete.carryForward.deletedSchedules;
|
|
634
|
+
expect(deleted).toHaveLength(2);
|
|
635
|
+
expect(deleted[0].scheduleId.toString()).toBe("schedule-1");
|
|
636
|
+
expect(ids(deleted[0].userIds)).toEqual(["user-a", "user-b"]);
|
|
637
|
+
expect(deleted[1].scheduleId.toString()).toBe("schedule-2");
|
|
638
|
+
expect(ids(deleted[1].userIds)).toEqual(["user-c"]);
|
|
639
|
+
|
|
640
|
+
const propagate: any = spyPropagate();
|
|
641
|
+
|
|
642
|
+
await (OnCallDutyPolicyScheduleService as any).onDeleteSuccess(onDelete, [
|
|
643
|
+
SCHEDULE_1,
|
|
644
|
+
SCHEDULE_2,
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
expect(propagate).toHaveBeenCalledTimes(2);
|
|
648
|
+
for (const call of propagate.mock.calls) {
|
|
649
|
+
const arg: any = call[0];
|
|
650
|
+
expect(arg.skipVersionBump).toBe(true);
|
|
651
|
+
expect(arg.reason).toBe(OnCallShiftChangeReason.ScheduleDeleted);
|
|
652
|
+
expect(arg.projectId).toBe(PROJECT_ID);
|
|
653
|
+
}
|
|
654
|
+
expect(ids(propagate.mock.calls[0]![0].userIds)).toEqual([
|
|
655
|
+
"user-a",
|
|
656
|
+
"user-b",
|
|
657
|
+
]);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("onBeforeDelete stays best-effort when the member lookup fails", async () => {
|
|
661
|
+
jest
|
|
662
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findBy")
|
|
663
|
+
.mockResolvedValue([
|
|
664
|
+
{ id: SCHEDULE_1, _id: SCHEDULE_1, projectId: PROJECT_ID },
|
|
665
|
+
] as never);
|
|
666
|
+
jest
|
|
667
|
+
.spyOn(OnCallDutyPolicyTimeLogService, "endTimeForSchedule")
|
|
668
|
+
.mockResolvedValue(undefined as never);
|
|
669
|
+
jest
|
|
670
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
671
|
+
.mockRejectedValue(new Error("db down"));
|
|
672
|
+
|
|
673
|
+
const onDelete: any = await (
|
|
674
|
+
OnCallDutyPolicyScheduleService as any
|
|
675
|
+
).onBeforeDelete({ query: { _id: SCHEDULE_1 }, props: { isRoot: true } });
|
|
676
|
+
|
|
677
|
+
expect(onDelete.carryForward.deletedSchedules).toEqual([]);
|
|
678
|
+
expect(onDelete.deleteBy.query._id).toBe(SCHEDULE_1);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("hook wiring: OnCallDutyPolicyScheduleLayerService", () => {
|
|
683
|
+
beforeEach(silenceLogger);
|
|
684
|
+
afterEach(() => {
|
|
685
|
+
jest.restoreAllMocks();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("onCreateSuccess: roster refresh, then propagate LayerChanged", async () => {
|
|
689
|
+
jest
|
|
690
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
|
|
691
|
+
.mockResolvedValue({
|
|
692
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
693
|
+
projectId: PROJECT_ID,
|
|
694
|
+
} as never);
|
|
695
|
+
const refresh: any = spyRosterRefresh();
|
|
696
|
+
const propagate: any = spyPropagate();
|
|
697
|
+
|
|
698
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
|
|
699
|
+
{},
|
|
700
|
+
{ id: LAYER_1 },
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
704
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
705
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
706
|
+
scheduleIds: [SCHEDULE_1],
|
|
707
|
+
projectId: PROJECT_ID,
|
|
708
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
709
|
+
});
|
|
710
|
+
expectCalledAfter(propagate, refresh);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("onCreateSuccess: nothing happens when the row has no schedule", async () => {
|
|
714
|
+
jest
|
|
715
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
|
|
716
|
+
.mockResolvedValue({} as never);
|
|
717
|
+
const refresh: any = spyRosterRefresh();
|
|
718
|
+
const propagate: any = spyPropagate();
|
|
719
|
+
|
|
720
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
|
|
721
|
+
{},
|
|
722
|
+
{ id: LAYER_1 },
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
726
|
+
expect(propagate).not.toHaveBeenCalled();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("onUpdateSuccess propagates once per updated layer", async () => {
|
|
730
|
+
jest
|
|
731
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
|
|
732
|
+
.mockImplementation((args: any): Promise<any> => {
|
|
733
|
+
return Promise.resolve({
|
|
734
|
+
onCallDutyPolicyScheduleId:
|
|
735
|
+
args.id.toString() === "layer-1" ? SCHEDULE_1 : SCHEDULE_2,
|
|
736
|
+
projectId: PROJECT_ID,
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
const refresh: any = spyRosterRefresh();
|
|
740
|
+
const propagate: any = spyPropagate();
|
|
741
|
+
|
|
742
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onUpdateSuccess(
|
|
743
|
+
{ updateBy: { data: {}, query: {}, props: {} }, carryForward: null },
|
|
744
|
+
[LAYER_1, new ObjectID("layer-2")],
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
expect(refresh).toHaveBeenCalledTimes(2);
|
|
748
|
+
expect(propagate).toHaveBeenCalledTimes(2);
|
|
749
|
+
expect(
|
|
750
|
+
propagate.mock.calls.map((c: Array<any>) => {
|
|
751
|
+
return ids(c[0].scheduleIds)[0];
|
|
752
|
+
}),
|
|
753
|
+
).toEqual(["schedule-1", "schedule-2"]);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("onBeforeDelete captures projectId; onDeleteSuccess re-sequences, refreshes, then propagates", async () => {
|
|
757
|
+
jest
|
|
758
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findOneBy")
|
|
759
|
+
.mockResolvedValue({
|
|
760
|
+
order: 2,
|
|
761
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
762
|
+
projectId: PROJECT_ID,
|
|
763
|
+
} as never);
|
|
764
|
+
jest
|
|
765
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findBy")
|
|
766
|
+
.mockResolvedValue([] as never);
|
|
767
|
+
|
|
768
|
+
const deleteBy: any = { query: { _id: LAYER_1 }, props: {} };
|
|
769
|
+
const onDelete: any = await (
|
|
770
|
+
OnCallDutyPolicyScheduleLayerService as any
|
|
771
|
+
).onBeforeDelete(deleteBy);
|
|
772
|
+
|
|
773
|
+
expect(onDelete.carryForward.projectId).toBe(PROJECT_ID);
|
|
774
|
+
|
|
775
|
+
const refresh: any = spyRosterRefresh();
|
|
776
|
+
const propagate: any = spyPropagate();
|
|
777
|
+
|
|
778
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onDeleteSuccess(
|
|
779
|
+
onDelete,
|
|
780
|
+
[LAYER_1],
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
784
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
785
|
+
scheduleIds: [SCHEDULE_1],
|
|
786
|
+
projectId: PROJECT_ID,
|
|
787
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
788
|
+
});
|
|
789
|
+
expectCalledAfter(propagate, refresh);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("a root delete (no carried resource) neither refreshes nor propagates — unchanged behaviour", async () => {
|
|
793
|
+
const refresh: any = spyRosterRefresh();
|
|
794
|
+
const propagate: any = spyPropagate();
|
|
795
|
+
|
|
796
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onDeleteSuccess(
|
|
797
|
+
{ deleteBy: { query: {}, props: { isRoot: true } }, carryForward: null },
|
|
798
|
+
[LAYER_1],
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
802
|
+
expect(propagate).not.toHaveBeenCalled();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
/*
|
|
806
|
+
* Same contract as the layer-user hooks: the layer row is committed before
|
|
807
|
+
* the success hook runs, so a throwing roster refresh must not swallow the
|
|
808
|
+
* version bump and the cache purge.
|
|
809
|
+
*/
|
|
810
|
+
test("a throwing roster refresh still propagates the layer change", async () => {
|
|
811
|
+
jest
|
|
812
|
+
.spyOn(OnCallDutyPolicyScheduleLayerService, "findOneById")
|
|
813
|
+
.mockResolvedValue({
|
|
814
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
815
|
+
projectId: PROJECT_ID,
|
|
816
|
+
} as never);
|
|
817
|
+
const refresh: any = spyRosterRefresh();
|
|
818
|
+
refresh.mockRejectedValue(new Error("Schedule not found"));
|
|
819
|
+
const propagate: any = spyPropagate();
|
|
820
|
+
|
|
821
|
+
await (OnCallDutyPolicyScheduleLayerService as any).onCreateSuccess(
|
|
822
|
+
{},
|
|
823
|
+
{ id: LAYER_1 },
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
827
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
828
|
+
scheduleIds: [SCHEDULE_1],
|
|
829
|
+
projectId: PROJECT_ID,
|
|
830
|
+
reason: OnCallShiftChangeReason.LayerChanged,
|
|
831
|
+
});
|
|
832
|
+
expect(logger.error).toHaveBeenCalled();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
describe("hook wiring: OnCallDutyPolicyScheduleLayerUserService", () => {
|
|
837
|
+
beforeEach(silenceLogger);
|
|
838
|
+
afterEach(() => {
|
|
839
|
+
jest.restoreAllMocks();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("onCreateSuccess names the added user, after the roster refresh", async () => {
|
|
843
|
+
jest
|
|
844
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
|
|
845
|
+
.mockResolvedValue({
|
|
846
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
847
|
+
projectId: PROJECT_ID,
|
|
848
|
+
userId: USER_A,
|
|
849
|
+
} as never);
|
|
850
|
+
const refresh: any = spyRosterRefresh();
|
|
851
|
+
const propagate: any = spyPropagate();
|
|
852
|
+
|
|
853
|
+
await (OnCallDutyPolicyScheduleLayerUserService as any).onCreateSuccess(
|
|
854
|
+
{},
|
|
855
|
+
{ id: ROW_ID },
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
859
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
860
|
+
scheduleIds: [SCHEDULE_1],
|
|
861
|
+
projectId: PROJECT_ID,
|
|
862
|
+
userIds: [USER_A],
|
|
863
|
+
reason: OnCallShiftChangeReason.LayerUserChanged,
|
|
864
|
+
});
|
|
865
|
+
expectCalledAfter(propagate, refresh);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("onUpdateSuccess (re-order) propagates per row", async () => {
|
|
869
|
+
jest
|
|
870
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
|
|
871
|
+
.mockResolvedValue({
|
|
872
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
873
|
+
projectId: PROJECT_ID,
|
|
874
|
+
userId: USER_B,
|
|
875
|
+
} as never);
|
|
876
|
+
spyRosterRefresh();
|
|
877
|
+
const propagate: any = spyPropagate();
|
|
878
|
+
|
|
879
|
+
await (OnCallDutyPolicyScheduleLayerUserService as any).onUpdateSuccess(
|
|
880
|
+
{ updateBy: { data: { order: 1 }, query: {}, props: {} } },
|
|
881
|
+
[ROW_ID],
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
885
|
+
expect(ids(lastCallArg(propagate).userIds)).toEqual(["user-b"]);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("onBeforeDelete captures user + project; onDeleteSuccess propagates with the removed user", async () => {
|
|
889
|
+
jest
|
|
890
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneBy")
|
|
891
|
+
.mockResolvedValue({
|
|
892
|
+
order: 1,
|
|
893
|
+
onCallDutyPolicyScheduleLayerId: LAYER_1,
|
|
894
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
895
|
+
projectId: PROJECT_ID,
|
|
896
|
+
userId: USER_C,
|
|
897
|
+
} as never);
|
|
898
|
+
jest
|
|
899
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
900
|
+
.mockResolvedValue([] as never);
|
|
901
|
+
|
|
902
|
+
const onDelete: any = await (
|
|
903
|
+
OnCallDutyPolicyScheduleLayerUserService as any
|
|
904
|
+
).onBeforeDelete({ query: { _id: ROW_ID }, props: {} });
|
|
905
|
+
|
|
906
|
+
expect(onDelete.carryForward.userId).toBe(USER_C);
|
|
907
|
+
expect(onDelete.carryForward.projectId).toBe(PROJECT_ID);
|
|
908
|
+
|
|
909
|
+
const refresh: any = spyRosterRefresh();
|
|
910
|
+
const propagate: any = spyPropagate();
|
|
911
|
+
|
|
912
|
+
await (OnCallDutyPolicyScheduleLayerUserService as any).onDeleteSuccess(
|
|
913
|
+
onDelete,
|
|
914
|
+
[ROW_ID],
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
918
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
919
|
+
scheduleIds: [SCHEDULE_1],
|
|
920
|
+
projectId: PROJECT_ID,
|
|
921
|
+
userIds: [USER_C],
|
|
922
|
+
reason: OnCallShiftChangeReason.LayerUserChanged,
|
|
923
|
+
});
|
|
924
|
+
expectCalledAfter(propagate, refresh);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("resequenceOrderInLayer renumbers 1..n and leaves already-correct rows alone", async () => {
|
|
928
|
+
jest
|
|
929
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
930
|
+
.mockResolvedValue([
|
|
931
|
+
{ _id: new ObjectID("r1"), order: 1 },
|
|
932
|
+
{ _id: new ObjectID("r2"), order: 3 },
|
|
933
|
+
{ _id: new ObjectID("r3"), order: 7 },
|
|
934
|
+
] as never);
|
|
935
|
+
const update: any = jest
|
|
936
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "updateOneBy")
|
|
937
|
+
.mockResolvedValue(undefined as never);
|
|
938
|
+
|
|
939
|
+
await OnCallDutyPolicyScheduleLayerUserService.resequenceOrderInLayer(
|
|
940
|
+
LAYER_1,
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
944
|
+
expect(update.mock.calls[0]![0].query._id.toString()).toBe("r2");
|
|
945
|
+
expect(update.mock.calls[0]![0].data).toEqual({ order: 2 });
|
|
946
|
+
expect(update.mock.calls[1]![0].query._id.toString()).toBe("r3");
|
|
947
|
+
expect(update.mock.calls[1]![0].data).toEqual({ order: 3 });
|
|
948
|
+
/*
|
|
949
|
+
* ignoreHooks: renumbering is bookkeeping, not a roster change. With the
|
|
950
|
+
* hooks on, every renumbered row would run a full onUpdateSuccess
|
|
951
|
+
* (semaphore-locked roster refresh + version bump + cache purge +
|
|
952
|
+
* listener pass) — N-1 redundant refreshes per layer — and a throw from
|
|
953
|
+
* one of them would abort the rest of the renumbering. The caller
|
|
954
|
+
* refreshes and propagates once per schedule itself.
|
|
955
|
+
*/
|
|
956
|
+
expect(update.mock.calls[0]![0].props).toEqual({
|
|
957
|
+
isRoot: true,
|
|
958
|
+
ignoreHooks: true,
|
|
959
|
+
});
|
|
960
|
+
expect(update.mock.calls[1]![0].props).toEqual({
|
|
961
|
+
isRoot: true,
|
|
962
|
+
ignoreHooks: true,
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
/*
|
|
967
|
+
* The row is already committed when the success hooks run, so a throwing
|
|
968
|
+
* roster refresh must not stop the version bump and the cache purge:
|
|
969
|
+
* otherwise the feed caches keep serving the pre-edit roster for up to
|
|
970
|
+
* their TTL (an hour for schedule segments) and the reminder change pass
|
|
971
|
+
* for that edit never runs.
|
|
972
|
+
*/
|
|
973
|
+
test("a throwing roster refresh still propagates: onCreateSuccess", async () => {
|
|
974
|
+
jest
|
|
975
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findOneById")
|
|
976
|
+
.mockResolvedValue({
|
|
977
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
978
|
+
projectId: PROJECT_ID,
|
|
979
|
+
userId: USER_A,
|
|
980
|
+
} as never);
|
|
981
|
+
const refresh: any = spyRosterRefresh();
|
|
982
|
+
refresh.mockRejectedValue(new Error("Schedule not found"));
|
|
983
|
+
const propagate: any = spyPropagate();
|
|
984
|
+
|
|
985
|
+
await expect(
|
|
986
|
+
(OnCallDutyPolicyScheduleLayerUserService as any).onCreateSuccess(
|
|
987
|
+
{},
|
|
988
|
+
{ id: ROW_ID },
|
|
989
|
+
),
|
|
990
|
+
).resolves.toBeDefined();
|
|
991
|
+
|
|
992
|
+
expect(refresh).toHaveBeenCalledWith(SCHEDULE_1);
|
|
993
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
994
|
+
scheduleIds: [SCHEDULE_1],
|
|
995
|
+
projectId: PROJECT_ID,
|
|
996
|
+
userIds: [USER_A],
|
|
997
|
+
reason: OnCallShiftChangeReason.LayerUserChanged,
|
|
998
|
+
});
|
|
999
|
+
expect(logger.error).toHaveBeenCalled();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("a throwing roster refresh still propagates: onDeleteSuccess", async () => {
|
|
1003
|
+
jest
|
|
1004
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
1005
|
+
.mockResolvedValue([] as never);
|
|
1006
|
+
jest
|
|
1007
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "updateOneBy")
|
|
1008
|
+
.mockResolvedValue(undefined as never);
|
|
1009
|
+
const refresh: any = spyRosterRefresh();
|
|
1010
|
+
refresh.mockRejectedValue(new Error("redis down"));
|
|
1011
|
+
const propagate: any = spyPropagate();
|
|
1012
|
+
|
|
1013
|
+
await (OnCallDutyPolicyScheduleLayerUserService as any).onDeleteSuccess(
|
|
1014
|
+
{
|
|
1015
|
+
deleteBy: { query: { _id: ROW_ID }, props: {} },
|
|
1016
|
+
carryForward: {
|
|
1017
|
+
order: 1,
|
|
1018
|
+
onCallDutyPolicyScheduleLayerId: LAYER_1,
|
|
1019
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
1020
|
+
projectId: PROJECT_ID,
|
|
1021
|
+
userId: USER_A,
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
[ROW_ID],
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
1028
|
+
scheduleIds: [SCHEDULE_1],
|
|
1029
|
+
projectId: PROJECT_ID,
|
|
1030
|
+
userIds: [USER_A],
|
|
1031
|
+
reason: OnCallShiftChangeReason.LayerUserChanged,
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
describe("hook wiring: OnCallDutyPolicyUserOverrideService", () => {
|
|
1037
|
+
beforeEach(silenceLogger);
|
|
1038
|
+
afterEach(() => {
|
|
1039
|
+
jest.restoreAllMocks();
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
function stubScheduleLookup(scheduleIds: Array<ObjectID>): any {
|
|
1043
|
+
return jest
|
|
1044
|
+
.spyOn(OnCallDutyPolicyScheduleService, "getScheduleIdsForUsersInProject")
|
|
1045
|
+
.mockResolvedValue(scheduleIds);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
test("onCreateSuccess: schedules of the overridden user are bumped; both users are named", async () => {
|
|
1049
|
+
const refreshRosters: any = jest
|
|
1050
|
+
.spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
|
|
1051
|
+
.mockResolvedValue(undefined);
|
|
1052
|
+
const lookup: any = stubScheduleLookup([SCHEDULE_1, SCHEDULE_2]);
|
|
1053
|
+
const propagate: any = spyPropagate();
|
|
1054
|
+
|
|
1055
|
+
await (OnCallDutyPolicyUserOverrideService as any).onCreateSuccess(
|
|
1056
|
+
{},
|
|
1057
|
+
{
|
|
1058
|
+
projectId: PROJECT_ID,
|
|
1059
|
+
overrideUserId: USER_A,
|
|
1060
|
+
routeAlertsToUserId: USER_C,
|
|
1061
|
+
// no onCallDutyPolicyId: global override, no policy feed item
|
|
1062
|
+
},
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
expect(refreshRosters).toHaveBeenCalledTimes(1);
|
|
1066
|
+
expect(lookup).toHaveBeenCalledTimes(1);
|
|
1067
|
+
expect(ids(lookup.mock.calls[0]![0].userIds)).toEqual(["user-a"]);
|
|
1068
|
+
|
|
1069
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
1070
|
+
const arg: any = lastCallArg(propagate);
|
|
1071
|
+
expect(ids(arg.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
|
|
1072
|
+
expect(arg.projectId).toBe(PROJECT_ID);
|
|
1073
|
+
expect(ids(arg.userIds)).toEqual(["user-a", "user-c"]);
|
|
1074
|
+
expect(arg.reason).toBe(OnCallShiftChangeReason.OverrideChanged);
|
|
1075
|
+
expectCalledAfter(propagate, refreshRosters);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("onBeforeUpdate captures the substitute as well", async () => {
|
|
1079
|
+
jest
|
|
1080
|
+
.spyOn(OnCallDutyPolicyUserOverrideService, "findBy")
|
|
1081
|
+
.mockResolvedValue([
|
|
1082
|
+
{
|
|
1083
|
+
projectId: PROJECT_ID,
|
|
1084
|
+
overrideUserId: USER_A,
|
|
1085
|
+
routeAlertsToUserId: USER_C,
|
|
1086
|
+
},
|
|
1087
|
+
] as never);
|
|
1088
|
+
|
|
1089
|
+
const onUpdate: any = await (
|
|
1090
|
+
OnCallDutyPolicyUserOverrideService as any
|
|
1091
|
+
).onBeforeUpdate({ query: { _id: ROW_ID }, data: {}, props: {} });
|
|
1092
|
+
|
|
1093
|
+
expect(onUpdate.carryForward).toHaveLength(1);
|
|
1094
|
+
expect(onUpdate.carryForward[0].overrideUserId).toBe(USER_A);
|
|
1095
|
+
expect(onUpdate.carryForward[0].routeAlertsToUserId).toBe(USER_C);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("onUpdateSuccess: OLD and NEW overridden users' schedules, all four users named, one propagate per project", async () => {
|
|
1099
|
+
jest
|
|
1100
|
+
.spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
|
|
1101
|
+
.mockResolvedValue(undefined);
|
|
1102
|
+
jest
|
|
1103
|
+
.spyOn(OnCallDutyPolicyUserOverrideService, "findOneById")
|
|
1104
|
+
.mockResolvedValue({
|
|
1105
|
+
projectId: PROJECT_ID,
|
|
1106
|
+
overrideUserId: USER_B,
|
|
1107
|
+
routeAlertsToUserId: USER_D,
|
|
1108
|
+
} as never);
|
|
1109
|
+
const lookup: any = stubScheduleLookup([SCHEDULE_1]);
|
|
1110
|
+
const propagate: any = spyPropagate();
|
|
1111
|
+
|
|
1112
|
+
await (OnCallDutyPolicyUserOverrideService as any).onUpdateSuccess(
|
|
1113
|
+
{
|
|
1114
|
+
updateBy: { query: { _id: ROW_ID }, data: {}, props: {} },
|
|
1115
|
+
carryForward: [
|
|
1116
|
+
{
|
|
1117
|
+
projectId: PROJECT_ID,
|
|
1118
|
+
overrideUserId: USER_A,
|
|
1119
|
+
routeAlertsToUserId: USER_C,
|
|
1120
|
+
},
|
|
1121
|
+
],
|
|
1122
|
+
},
|
|
1123
|
+
[ROW_ID],
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
expect(lookup).toHaveBeenCalledTimes(1);
|
|
1127
|
+
expect(ids(lookup.mock.calls[0]![0].userIds)).toEqual(["user-a", "user-b"]);
|
|
1128
|
+
|
|
1129
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
1130
|
+
const arg: any = lastCallArg(propagate);
|
|
1131
|
+
expect(ids(arg.scheduleIds)).toEqual(["schedule-1"]);
|
|
1132
|
+
expect(ids(arg.userIds).sort()).toEqual([
|
|
1133
|
+
"user-a",
|
|
1134
|
+
"user-b",
|
|
1135
|
+
"user-c",
|
|
1136
|
+
"user-d",
|
|
1137
|
+
]);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test("onDeleteSuccess propagates for the deleted overrides", async () => {
|
|
1141
|
+
jest
|
|
1142
|
+
.spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
|
|
1143
|
+
.mockResolvedValue(undefined);
|
|
1144
|
+
stubScheduleLookup([SCHEDULE_2]);
|
|
1145
|
+
const propagate: any = spyPropagate();
|
|
1146
|
+
|
|
1147
|
+
await (OnCallDutyPolicyUserOverrideService as any).onDeleteSuccess(
|
|
1148
|
+
{
|
|
1149
|
+
deleteBy: { query: {}, props: {} },
|
|
1150
|
+
carryForward: [
|
|
1151
|
+
{
|
|
1152
|
+
projectId: PROJECT_ID,
|
|
1153
|
+
overrideUserId: USER_A,
|
|
1154
|
+
routeAlertsToUserId: USER_C,
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
},
|
|
1158
|
+
[ROW_ID],
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
expect(propagate).toHaveBeenCalledTimes(1);
|
|
1162
|
+
expect(ids(lastCallArg(propagate).scheduleIds)).toEqual(["schedule-2"]);
|
|
1163
|
+
expect(lastCallArg(propagate).reason).toBe(
|
|
1164
|
+
OnCallShiftChangeReason.OverrideChanged,
|
|
1165
|
+
);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test("a failing schedule lookup is logged and never thrown", async () => {
|
|
1169
|
+
jest
|
|
1170
|
+
.spyOn(OnCallDutyPolicyScheduleService, "refreshRostersForUserInProject")
|
|
1171
|
+
.mockResolvedValue(undefined);
|
|
1172
|
+
jest
|
|
1173
|
+
.spyOn(OnCallDutyPolicyScheduleService, "getScheduleIdsForUsersInProject")
|
|
1174
|
+
.mockRejectedValue(new Error("db down"));
|
|
1175
|
+
const propagate: any = spyPropagate();
|
|
1176
|
+
|
|
1177
|
+
await expect(
|
|
1178
|
+
(OnCallDutyPolicyUserOverrideService as any).onDeleteSuccess(
|
|
1179
|
+
{
|
|
1180
|
+
deleteBy: { query: {}, props: {} },
|
|
1181
|
+
carryForward: [{ projectId: PROJECT_ID, overrideUserId: USER_A }],
|
|
1182
|
+
},
|
|
1183
|
+
[ROW_ID],
|
|
1184
|
+
),
|
|
1185
|
+
).resolves.toBeDefined();
|
|
1186
|
+
|
|
1187
|
+
expect(propagate).not.toHaveBeenCalled();
|
|
1188
|
+
expect(logger.error).toHaveBeenCalled();
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
describe("hook wiring: OnCallDutyPolicyEscalationRuleScheduleService", () => {
|
|
1193
|
+
beforeEach(() => {
|
|
1194
|
+
silenceLogger();
|
|
1195
|
+
jest
|
|
1196
|
+
.spyOn(OnCallDutyPolicyScheduleService, "getCurrentUserIdInSchedule")
|
|
1197
|
+
.mockResolvedValue(null);
|
|
1198
|
+
jest
|
|
1199
|
+
.spyOn(OnCallDutyPolicyFeedService, "createOnCallDutyPolicyFeedItem")
|
|
1200
|
+
.mockResolvedValue(undefined as never);
|
|
1201
|
+
jest
|
|
1202
|
+
.spyOn(OnCallDutyPolicyTimeLogService, "startTimeLogForUser")
|
|
1203
|
+
.mockResolvedValue(undefined as never);
|
|
1204
|
+
jest
|
|
1205
|
+
.spyOn(OnCallDutyPolicyTimeLogService, "endTimeLogForUser")
|
|
1206
|
+
.mockResolvedValue(undefined as never);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
afterEach(() => {
|
|
1210
|
+
jest.restoreAllMocks();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
function linkRow(scheduleId: ObjectID): any {
|
|
1214
|
+
return {
|
|
1215
|
+
id: ROW_ID,
|
|
1216
|
+
projectId: PROJECT_ID,
|
|
1217
|
+
onCallDutyPolicyScheduleId: scheduleId,
|
|
1218
|
+
onCallDutyPolicySchedule: { name: "Primary schedule" },
|
|
1219
|
+
onCallDutyPolicyEscalationRule: { name: "Rule 1", id: RULE_ID, order: 1 },
|
|
1220
|
+
onCallDutyPolicy: { name: "Payments", id: POLICY_ID },
|
|
1221
|
+
createdByUserId: null,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
test("attaching propagates PolicyAttachmentChanged after the roster refresh", async () => {
|
|
1226
|
+
jest
|
|
1227
|
+
.spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findOneById")
|
|
1228
|
+
.mockResolvedValue(linkRow(SCHEDULE_1));
|
|
1229
|
+
const refresh: any = spyRosterRefresh();
|
|
1230
|
+
const propagate: any = spyPropagate();
|
|
1231
|
+
|
|
1232
|
+
await (
|
|
1233
|
+
OnCallDutyPolicyEscalationRuleScheduleService as any
|
|
1234
|
+
).onCreateSuccess({}, { id: ROW_ID });
|
|
1235
|
+
|
|
1236
|
+
expect(lastCallArg(propagate)).toEqual({
|
|
1237
|
+
scheduleIds: [SCHEDULE_1],
|
|
1238
|
+
projectId: PROJECT_ID,
|
|
1239
|
+
reason: OnCallShiftChangeReason.PolicyAttachmentChanged,
|
|
1240
|
+
});
|
|
1241
|
+
expectCalledAfter(propagate, refresh);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("detaching propagates once per distinct schedule", async () => {
|
|
1245
|
+
spyRosterRefresh();
|
|
1246
|
+
const propagate: any = spyPropagate();
|
|
1247
|
+
|
|
1248
|
+
await (
|
|
1249
|
+
OnCallDutyPolicyEscalationRuleScheduleService as any
|
|
1250
|
+
).onDeleteSuccess(
|
|
1251
|
+
{
|
|
1252
|
+
carryForward: {
|
|
1253
|
+
deletedItems: [
|
|
1254
|
+
linkRow(SCHEDULE_1),
|
|
1255
|
+
linkRow(SCHEDULE_1),
|
|
1256
|
+
linkRow(SCHEDULE_2),
|
|
1257
|
+
],
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1260
|
+
[ROW_ID],
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
expect(propagate).toHaveBeenCalledTimes(2);
|
|
1264
|
+
expect(
|
|
1265
|
+
propagate.mock.calls.map((c: Array<any>) => {
|
|
1266
|
+
return ids(c[0].scheduleIds)[0];
|
|
1267
|
+
}),
|
|
1268
|
+
).toEqual(["schedule-1", "schedule-2"]);
|
|
1269
|
+
});
|
|
1270
|
+
});
|