@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,755 @@
|
|
|
1
|
+
import TeamMemberService, {
|
|
2
|
+
OnCallLeaveCleanupResult,
|
|
3
|
+
} from "../../../Server/Services/TeamMemberService";
|
|
4
|
+
import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
|
|
5
|
+
import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
|
|
6
|
+
import OnCallDutyPolicyEscalationRuleUserService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleUserService";
|
|
7
|
+
import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
|
|
8
|
+
import UserOnCallCalendarFeedService from "../../../Server/Services/UserOnCallCalendarFeedService";
|
|
9
|
+
import UserOnCallShiftReminderService from "../../../Server/Services/UserOnCallShiftReminderService";
|
|
10
|
+
import OnCallDutyPolicyScheduleCalendarFeedService from "../../../Server/Services/OnCallDutyPolicyScheduleCalendarFeedService";
|
|
11
|
+
import ProjectOnCallCalendarFeedService from "../../../Server/Services/ProjectOnCallCalendarFeedService";
|
|
12
|
+
import UserNotificationSettingService from "../../../Server/Services/UserNotificationSettingService";
|
|
13
|
+
import OnCallCalendarFeedCache from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
|
|
14
|
+
import { OnCallShiftChangeReason } from "../../../Server/Utils/OnCall/OnCallShiftChangeListeners";
|
|
15
|
+
import logger from "../../../Server/Utils/Logger";
|
|
16
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
17
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
18
|
+
import {
|
|
19
|
+
afterEach,
|
|
20
|
+
beforeEach,
|
|
21
|
+
describe,
|
|
22
|
+
expect,
|
|
23
|
+
jest,
|
|
24
|
+
test,
|
|
25
|
+
} from "@jest/globals";
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Decision 5 of the calendar-feeds spec: when a user's LAST accepted team
|
|
29
|
+
* membership in a project goes away, their on-call assignments in that
|
|
30
|
+
* project go with it. Nothing did this before — the layer-user and
|
|
31
|
+
* escalation-rule-user rows survived, so schedules kept rotating onto an
|
|
32
|
+
* ex-member.
|
|
33
|
+
*
|
|
34
|
+
* The rule is the one removeDefaultNotificationSettingsForUser already uses:
|
|
35
|
+
* countBy({projectId, userId, hasAcceptedInvitation: true}) === 0. Everything
|
|
36
|
+
* the cleanup touches is spied; no Postgres or Redis involved.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const PROJECT_ID: ObjectID = new ObjectID("project-1");
|
|
40
|
+
const USER_ID: ObjectID = new ObjectID("user-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 LAYER_2: ObjectID = new ObjectID("layer-2");
|
|
45
|
+
const FEED_1: ObjectID = new ObjectID("feed-1");
|
|
46
|
+
const PROJECT_FEED: ObjectID = new ObjectID("project-feed-1");
|
|
47
|
+
|
|
48
|
+
function ids(list: Array<ObjectID>): Array<string> {
|
|
49
|
+
return list.map((id: ObjectID) => {
|
|
50
|
+
return id.toString();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Spies {
|
|
55
|
+
countMembers: any;
|
|
56
|
+
layerUsersFindBy: any;
|
|
57
|
+
layerUsersDeleteBy: any;
|
|
58
|
+
resequence: any;
|
|
59
|
+
ruleUsersCountBy: any;
|
|
60
|
+
ruleUsersDeleteBy: any;
|
|
61
|
+
overridesDeleteBy: any;
|
|
62
|
+
refreshRoster: any;
|
|
63
|
+
propagate: any;
|
|
64
|
+
feedCountBy: any;
|
|
65
|
+
feedUpdateOneBy: any;
|
|
66
|
+
purgeForUser: any;
|
|
67
|
+
purgeForProject: any;
|
|
68
|
+
remindersDeleteBy: any;
|
|
69
|
+
rotateScheduleFeeds: any;
|
|
70
|
+
rotateProjectFeeds: any;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* The "everything is there" world: two layer memberships on two schedules,
|
|
75
|
+
* one escalation-rule membership, a personal feed, two reminders, one
|
|
76
|
+
* flagged schedule feed and no project feed.
|
|
77
|
+
*/
|
|
78
|
+
function stubWorld(options?: {
|
|
79
|
+
remainingMemberships?: number;
|
|
80
|
+
layerUserRows?: Array<Record<string, unknown>>;
|
|
81
|
+
ruleUserCount?: number;
|
|
82
|
+
userOverrideCountPerSide?: number;
|
|
83
|
+
feedCount?: number;
|
|
84
|
+
rotatedScheduleFeeds?: Array<ObjectID>;
|
|
85
|
+
rotatedProjectFeeds?: Array<ObjectID>;
|
|
86
|
+
}): Spies {
|
|
87
|
+
const layerUserRows: Array<Record<string, unknown>> =
|
|
88
|
+
options?.layerUserRows ?? [
|
|
89
|
+
{
|
|
90
|
+
_id: new ObjectID("lu-1"),
|
|
91
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
92
|
+
onCallDutyPolicyScheduleLayerId: LAYER_1,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
_id: new ObjectID("lu-2"),
|
|
96
|
+
onCallDutyPolicyScheduleId: SCHEDULE_2,
|
|
97
|
+
onCallDutyPolicyScheduleLayerId: LAYER_2,
|
|
98
|
+
},
|
|
99
|
+
// Same schedule and layer twice (a user can sit in one layer twice).
|
|
100
|
+
{
|
|
101
|
+
_id: new ObjectID("lu-3"),
|
|
102
|
+
onCallDutyPolicyScheduleId: SCHEDULE_1,
|
|
103
|
+
onCallDutyPolicyScheduleLayerId: LAYER_1,
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
countMembers: jest
|
|
109
|
+
.spyOn(TeamMemberService, "countBy")
|
|
110
|
+
.mockResolvedValue(
|
|
111
|
+
new PositiveNumber(options?.remainingMemberships ?? 0) as never,
|
|
112
|
+
),
|
|
113
|
+
layerUsersFindBy: jest
|
|
114
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
|
|
115
|
+
.mockResolvedValue(layerUserRows as never),
|
|
116
|
+
layerUsersDeleteBy: jest
|
|
117
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "deleteBy")
|
|
118
|
+
.mockResolvedValue(layerUserRows.length as never),
|
|
119
|
+
resequence: jest
|
|
120
|
+
.spyOn(OnCallDutyPolicyScheduleLayerUserService, "resequenceOrderInLayer")
|
|
121
|
+
.mockResolvedValue(undefined),
|
|
122
|
+
ruleUsersCountBy: jest
|
|
123
|
+
.spyOn(OnCallDutyPolicyEscalationRuleUserService, "countBy")
|
|
124
|
+
.mockResolvedValue(
|
|
125
|
+
new PositiveNumber(options?.ruleUserCount ?? 1) as never,
|
|
126
|
+
),
|
|
127
|
+
ruleUsersDeleteBy: jest
|
|
128
|
+
.spyOn(OnCallDutyPolicyEscalationRuleUserService, "deleteBy")
|
|
129
|
+
.mockResolvedValue((options?.ruleUserCount ?? 1) as never),
|
|
130
|
+
overridesDeleteBy: jest
|
|
131
|
+
.spyOn(OnCallDutyPolicyUserOverrideService, "deleteBy")
|
|
132
|
+
.mockResolvedValue((options?.userOverrideCountPerSide ?? 1) as never),
|
|
133
|
+
refreshRoster: jest
|
|
134
|
+
.spyOn(
|
|
135
|
+
OnCallDutyPolicyScheduleService,
|
|
136
|
+
"refreshCurrentUserIdAndHandoffTimeInSchedule",
|
|
137
|
+
)
|
|
138
|
+
.mockResolvedValue({
|
|
139
|
+
currentUserId: null,
|
|
140
|
+
handOffTimeAt: null,
|
|
141
|
+
nextUserId: null,
|
|
142
|
+
nextHandOffTimeAt: null,
|
|
143
|
+
rosterStartAt: null,
|
|
144
|
+
nextRosterStartAt: null,
|
|
145
|
+
}),
|
|
146
|
+
propagate: jest
|
|
147
|
+
.spyOn(OnCallDutyPolicyScheduleService, "propagateShiftConfigChange")
|
|
148
|
+
.mockResolvedValue(undefined),
|
|
149
|
+
feedCountBy: jest
|
|
150
|
+
.spyOn(UserOnCallCalendarFeedService, "countBy")
|
|
151
|
+
.mockResolvedValue(new PositiveNumber(options?.feedCount ?? 1) as never),
|
|
152
|
+
feedUpdateOneBy: jest
|
|
153
|
+
.spyOn(UserOnCallCalendarFeedService, "updateOneBy")
|
|
154
|
+
.mockResolvedValue(undefined as never),
|
|
155
|
+
purgeForUser: jest
|
|
156
|
+
.spyOn(OnCallCalendarFeedCache, "purgeForUser")
|
|
157
|
+
.mockResolvedValue(undefined),
|
|
158
|
+
purgeForProject: jest
|
|
159
|
+
.spyOn(OnCallCalendarFeedCache, "purgeForProject")
|
|
160
|
+
.mockResolvedValue(undefined),
|
|
161
|
+
remindersDeleteBy: jest
|
|
162
|
+
.spyOn(UserOnCallShiftReminderService, "deleteBy")
|
|
163
|
+
.mockResolvedValue(2 as never),
|
|
164
|
+
rotateScheduleFeeds: jest
|
|
165
|
+
.spyOn(
|
|
166
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
167
|
+
"rotateFeedsForMemberLeave",
|
|
168
|
+
)
|
|
169
|
+
.mockResolvedValue(options?.rotatedScheduleFeeds ?? [FEED_1]),
|
|
170
|
+
rotateProjectFeeds: jest
|
|
171
|
+
.spyOn(ProjectOnCallCalendarFeedService, "rotateFeedsForMemberLeave")
|
|
172
|
+
.mockResolvedValue(options?.rotatedProjectFeeds ?? []),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("TeamMemberService on-call cleanup when a user leaves the project", () => {
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
jest.spyOn(logger, "error").mockImplementation((): void => {
|
|
179
|
+
return undefined;
|
|
180
|
+
});
|
|
181
|
+
jest.spyOn(logger, "debug").mockImplementation((): void => {
|
|
182
|
+
return undefined;
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
jest.restoreAllMocks();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("still a member of another team", () => {
|
|
191
|
+
test("nothing is removed, refreshed, disabled or rotated", async () => {
|
|
192
|
+
const spies: Spies = stubWorld({ remainingMemberships: 1 });
|
|
193
|
+
|
|
194
|
+
const result: OnCallLeaveCleanupResult | null =
|
|
195
|
+
await TeamMemberService.cleanupOnCallAssignmentsIfUserLeftProject({
|
|
196
|
+
projectId: PROJECT_ID,
|
|
197
|
+
userId: USER_ID,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
|
|
202
|
+
expect(spies.countMembers).toHaveBeenCalledWith({
|
|
203
|
+
query: {
|
|
204
|
+
projectId: PROJECT_ID,
|
|
205
|
+
userId: USER_ID,
|
|
206
|
+
hasAcceptedInvitation: true,
|
|
207
|
+
},
|
|
208
|
+
props: { isRoot: true },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(spies.layerUsersFindBy).not.toHaveBeenCalled();
|
|
212
|
+
expect(spies.layerUsersDeleteBy).not.toHaveBeenCalled();
|
|
213
|
+
expect(spies.ruleUsersDeleteBy).not.toHaveBeenCalled();
|
|
214
|
+
expect(spies.refreshRoster).not.toHaveBeenCalled();
|
|
215
|
+
expect(spies.propagate).not.toHaveBeenCalled();
|
|
216
|
+
expect(spies.feedUpdateOneBy).not.toHaveBeenCalled();
|
|
217
|
+
expect(spies.remindersDeleteBy).not.toHaveBeenCalled();
|
|
218
|
+
expect(spies.rotateScheduleFeeds).not.toHaveBeenCalled();
|
|
219
|
+
expect(spies.rotateProjectFeeds).not.toHaveBeenCalled();
|
|
220
|
+
expect(spies.purgeForUser).not.toHaveBeenCalled();
|
|
221
|
+
expect(spies.purgeForProject).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("last team in the project", () => {
|
|
226
|
+
test("removes layer users and rule users, refreshes rosters, bumps, disables the feed, deletes reminders, rotates flagged feeds", async () => {
|
|
227
|
+
const spies: Spies = stubWorld();
|
|
228
|
+
|
|
229
|
+
const result: OnCallLeaveCleanupResult | null =
|
|
230
|
+
await TeamMemberService.cleanupOnCallAssignmentsIfUserLeftProject({
|
|
231
|
+
projectId: PROJECT_ID,
|
|
232
|
+
userId: USER_ID,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result).toEqual({
|
|
236
|
+
removedLayerUserCount: 3,
|
|
237
|
+
removedEscalationRuleUserCount: 1,
|
|
238
|
+
// one delete per side of the override (overridden user, substitute).
|
|
239
|
+
removedUserOverrideCount: 2,
|
|
240
|
+
refreshedScheduleIds: ["schedule-1", "schedule-2"],
|
|
241
|
+
personalFeedDisabled: true,
|
|
242
|
+
removedReminderCount: 2,
|
|
243
|
+
rotatedScheduleFeedIds: ["feed-1"],
|
|
244
|
+
rotatedProjectFeedIds: [],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 1. Layer-user rows: one root delete over exactly {projectId, userId}.
|
|
248
|
+
expect(spies.layerUsersDeleteBy).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(spies.layerUsersDeleteBy.mock.calls[0]![0]).toMatchObject({
|
|
250
|
+
query: { projectId: PROJECT_ID, userId: USER_ID },
|
|
251
|
+
props: { isRoot: true },
|
|
252
|
+
});
|
|
253
|
+
// ...and each affected layer re-sequenced once.
|
|
254
|
+
expect(
|
|
255
|
+
ids(
|
|
256
|
+
spies.resequence.mock.calls.map((c: Array<any>) => {
|
|
257
|
+
return c[0];
|
|
258
|
+
}),
|
|
259
|
+
),
|
|
260
|
+
).toEqual(["layer-1", "layer-2"]);
|
|
261
|
+
|
|
262
|
+
// 2. Rule-user rows through the service (hooks close time logs, notify).
|
|
263
|
+
expect(spies.ruleUsersDeleteBy).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(spies.ruleUsersDeleteBy.mock.calls[0]![0]).toMatchObject({
|
|
265
|
+
query: { projectId: PROJECT_ID, userId: USER_ID },
|
|
266
|
+
props: { isRoot: true },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// 3. Every affected schedule's roster re-resolved exactly once.
|
|
270
|
+
expect(
|
|
271
|
+
ids(
|
|
272
|
+
spies.refreshRoster.mock.calls.map((c: Array<any>) => {
|
|
273
|
+
return c[0];
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
).toEqual(["schedule-1", "schedule-2"]);
|
|
277
|
+
|
|
278
|
+
// 4. Version bump / purge / listeners with the leaving user named.
|
|
279
|
+
expect(spies.propagate).toHaveBeenCalledTimes(1);
|
|
280
|
+
const change: any = spies.propagate.mock.calls[0]![0];
|
|
281
|
+
expect(ids(change.scheduleIds)).toEqual(["schedule-1", "schedule-2"]);
|
|
282
|
+
expect(change.projectId).toBe(PROJECT_ID);
|
|
283
|
+
expect(ids(change.userIds)).toEqual(["user-1"]);
|
|
284
|
+
expect(change.reason).toBe(OnCallShiftChangeReason.MemberLeftProject);
|
|
285
|
+
|
|
286
|
+
// 5. Personal feed disabled — not deleted — and its bodies purged.
|
|
287
|
+
expect(spies.feedUpdateOneBy).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(spies.feedUpdateOneBy.mock.calls[0]![0]).toEqual({
|
|
289
|
+
query: { projectId: PROJECT_ID, userId: USER_ID },
|
|
290
|
+
data: { isEnabled: false },
|
|
291
|
+
props: { isRoot: true },
|
|
292
|
+
});
|
|
293
|
+
expect(spies.purgeForUser).toHaveBeenCalledWith("project-1", "user-1");
|
|
294
|
+
|
|
295
|
+
// 6. Reminders gone.
|
|
296
|
+
expect(spies.remindersDeleteBy.mock.calls[0]![0]).toMatchObject({
|
|
297
|
+
query: { projectId: PROJECT_ID, userId: USER_ID },
|
|
298
|
+
props: { isRoot: true },
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// 7. Flagged feeds rotated through the owning services, project purged.
|
|
302
|
+
expect(spies.rotateScheduleFeeds).toHaveBeenCalledWith({
|
|
303
|
+
projectId: PROJECT_ID,
|
|
304
|
+
});
|
|
305
|
+
expect(spies.rotateProjectFeeds).toHaveBeenCalledWith({
|
|
306
|
+
projectId: PROJECT_ID,
|
|
307
|
+
});
|
|
308
|
+
expect(spies.purgeForProject).toHaveBeenCalledWith("project-1");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("deletes the project's still-running overrides on BOTH sides through the service, and leaves ended ones alone", async () => {
|
|
312
|
+
const spies: Spies = stubWorld();
|
|
313
|
+
const before: Date = new Date();
|
|
314
|
+
|
|
315
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
316
|
+
projectId: PROJECT_ID,
|
|
317
|
+
userId: USER_ID,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/*
|
|
321
|
+
* An override "route Alice's alerts to Bob" keeps paging Bob and keeps
|
|
322
|
+
* Bob's covering shifts in the feeds and in /my-shifts; "route Bob's
|
|
323
|
+
* alerts to Carol" is Bob's own, and Bob is gone. Both sides go, and
|
|
324
|
+
* only through the service — its hooks refresh the overridden
|
|
325
|
+
* colleague's roster and propagate.
|
|
326
|
+
*/
|
|
327
|
+
expect(spies.overridesDeleteBy).toHaveBeenCalledTimes(2);
|
|
328
|
+
|
|
329
|
+
const overrideSide: any = spies.overridesDeleteBy.mock.calls[0]![0];
|
|
330
|
+
const substituteSide: any = spies.overridesDeleteBy.mock.calls[1]![0];
|
|
331
|
+
|
|
332
|
+
expect(overrideSide.query.projectId).toBe(PROJECT_ID);
|
|
333
|
+
expect(overrideSide.query.overrideUserId).toBe(USER_ID);
|
|
334
|
+
expect(overrideSide.query.routeAlertsToUserId).toBeUndefined();
|
|
335
|
+
expect(overrideSide.props).toEqual({ isRoot: true });
|
|
336
|
+
|
|
337
|
+
expect(substituteSide.query.projectId).toBe(PROJECT_ID);
|
|
338
|
+
expect(substituteSide.query.routeAlertsToUserId).toBe(USER_ID);
|
|
339
|
+
expect(substituteSide.query.overrideUserId).toBeUndefined();
|
|
340
|
+
expect(substituteSide.props).toEqual({ isRoot: true });
|
|
341
|
+
|
|
342
|
+
// Ended overrides are history: both deletes are scoped to endsAt > now.
|
|
343
|
+
for (const call of [overrideSide, substituteSide]) {
|
|
344
|
+
const endsAt: any = call.query.endsAt;
|
|
345
|
+
expect(endsAt).toBeDefined();
|
|
346
|
+
expect(String(endsAt.getSql('"endsAt"'))).toContain('"endsAt" > :');
|
|
347
|
+
|
|
348
|
+
const boundValues: Array<unknown> = Object.values(
|
|
349
|
+
endsAt.objectLiteralParameters as Record<string, unknown>,
|
|
350
|
+
);
|
|
351
|
+
expect(boundValues).toHaveLength(1);
|
|
352
|
+
const bound: Date = boundValues[0] as Date;
|
|
353
|
+
expect(bound instanceof Date).toBe(true);
|
|
354
|
+
expect(bound.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
|
|
355
|
+
expect(bound.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("the overrides go before the rosters are refreshed and the change propagated", async () => {
|
|
360
|
+
const spies: Spies = stubWorld();
|
|
361
|
+
|
|
362
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
363
|
+
projectId: PROJECT_ID,
|
|
364
|
+
userId: USER_ID,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(spies.overridesDeleteBy.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
368
|
+
spies.refreshRoster.mock.invocationCallOrder[0]!,
|
|
369
|
+
);
|
|
370
|
+
expect(spies.overridesDeleteBy.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
371
|
+
spies.propagate.mock.invocationCallOrder[0]!,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("the order is: rows removed, rosters refreshed, then propagated, then feeds", async () => {
|
|
376
|
+
const spies: Spies = stubWorld();
|
|
377
|
+
|
|
378
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
379
|
+
projectId: PROJECT_ID,
|
|
380
|
+
userId: USER_ID,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const order: (spy: any) => number = (spy: any): number => {
|
|
384
|
+
return spy.mock.invocationCallOrder[0]!;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
expect(order(spies.layerUsersDeleteBy)).toBeLessThan(
|
|
388
|
+
order(spies.refreshRoster),
|
|
389
|
+
);
|
|
390
|
+
expect(order(spies.ruleUsersDeleteBy)).toBeLessThan(
|
|
391
|
+
order(spies.refreshRoster),
|
|
392
|
+
);
|
|
393
|
+
expect(order(spies.refreshRoster)).toBeLessThan(order(spies.propagate));
|
|
394
|
+
expect(order(spies.propagate)).toBeLessThan(order(spies.feedUpdateOneBy));
|
|
395
|
+
expect(order(spies.feedUpdateOneBy)).toBeLessThan(
|
|
396
|
+
order(spies.rotateScheduleFeeds),
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("nothing rotated => no project-wide purge; unflagged feeds are the services' business", async () => {
|
|
401
|
+
const spies: Spies = stubWorld({
|
|
402
|
+
rotatedScheduleFeeds: [],
|
|
403
|
+
rotatedProjectFeeds: [],
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const result: OnCallLeaveCleanupResult =
|
|
407
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
408
|
+
projectId: PROJECT_ID,
|
|
409
|
+
userId: USER_ID,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(result.rotatedScheduleFeedIds).toEqual([]);
|
|
413
|
+
expect(result.rotatedProjectFeedIds).toEqual([]);
|
|
414
|
+
expect(spies.purgeForProject).not.toHaveBeenCalled();
|
|
415
|
+
// The personal-feed purge still happens.
|
|
416
|
+
expect(spies.purgeForUser).toHaveBeenCalledTimes(1);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("a rotated project feed also purges the project", async () => {
|
|
420
|
+
const spies: Spies = stubWorld({
|
|
421
|
+
rotatedScheduleFeeds: [],
|
|
422
|
+
rotatedProjectFeeds: [PROJECT_FEED],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const result: OnCallLeaveCleanupResult =
|
|
426
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
427
|
+
projectId: PROJECT_ID,
|
|
428
|
+
userId: USER_ID,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(result.rotatedProjectFeedIds).toEqual(["project-feed-1"]);
|
|
432
|
+
expect(spies.purgeForProject).toHaveBeenCalledTimes(1);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("no on-call rows at all: no deletes, no refresh, but the feed/reminder/rotation steps still run", async () => {
|
|
436
|
+
const spies: Spies = stubWorld({
|
|
437
|
+
layerUserRows: [],
|
|
438
|
+
ruleUserCount: 0,
|
|
439
|
+
userOverrideCountPerSide: 0,
|
|
440
|
+
feedCount: 0,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const result: OnCallLeaveCleanupResult =
|
|
444
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
445
|
+
projectId: PROJECT_ID,
|
|
446
|
+
userId: USER_ID,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(spies.layerUsersDeleteBy).not.toHaveBeenCalled();
|
|
450
|
+
expect(spies.resequence).not.toHaveBeenCalled();
|
|
451
|
+
expect(spies.ruleUsersDeleteBy).not.toHaveBeenCalled();
|
|
452
|
+
expect(spies.refreshRoster).not.toHaveBeenCalled();
|
|
453
|
+
expect(spies.feedUpdateOneBy).not.toHaveBeenCalled();
|
|
454
|
+
|
|
455
|
+
// The listener event still names the user (their feed just changed).
|
|
456
|
+
expect(spies.propagate).toHaveBeenCalledTimes(1);
|
|
457
|
+
expect(spies.propagate.mock.calls[0]![0].scheduleIds).toEqual([]);
|
|
458
|
+
expect(ids(spies.propagate.mock.calls[0]![0].userIds)).toEqual([
|
|
459
|
+
"user-1",
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
expect(spies.purgeForUser).toHaveBeenCalledTimes(1);
|
|
463
|
+
expect(spies.remindersDeleteBy).toHaveBeenCalledTimes(1);
|
|
464
|
+
expect(spies.rotateScheduleFeeds).toHaveBeenCalledTimes(1);
|
|
465
|
+
|
|
466
|
+
expect(result).toEqual({
|
|
467
|
+
removedLayerUserCount: 0,
|
|
468
|
+
removedEscalationRuleUserCount: 0,
|
|
469
|
+
removedUserOverrideCount: 0,
|
|
470
|
+
refreshedScheduleIds: [],
|
|
471
|
+
personalFeedDisabled: false,
|
|
472
|
+
removedReminderCount: 2,
|
|
473
|
+
rotatedScheduleFeedIds: ["feed-1"],
|
|
474
|
+
rotatedProjectFeedIds: [],
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("failure isolation", () => {
|
|
480
|
+
test("a failing layer-user lookup does not stop the other steps", async () => {
|
|
481
|
+
const spies: Spies = stubWorld();
|
|
482
|
+
spies.layerUsersFindBy.mockRejectedValue(new Error("db down"));
|
|
483
|
+
|
|
484
|
+
const result: OnCallLeaveCleanupResult =
|
|
485
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
486
|
+
projectId: PROJECT_ID,
|
|
487
|
+
userId: USER_ID,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
expect(result.removedLayerUserCount).toBe(0);
|
|
491
|
+
expect(result.refreshedScheduleIds).toEqual([]);
|
|
492
|
+
expect(result.removedEscalationRuleUserCount).toBe(1);
|
|
493
|
+
expect(result.personalFeedDisabled).toBe(true);
|
|
494
|
+
expect(result.removedReminderCount).toBe(2);
|
|
495
|
+
expect(result.rotatedScheduleFeedIds).toEqual(["feed-1"]);
|
|
496
|
+
expect(logger.error).toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("a failing roster refresh on one schedule still refreshes the other and continues", async () => {
|
|
500
|
+
const spies: Spies = stubWorld();
|
|
501
|
+
spies.refreshRoster.mockImplementation((id: ObjectID): Promise<any> => {
|
|
502
|
+
if (id.toString() === "schedule-1") {
|
|
503
|
+
return Promise.reject(new Error("redis down"));
|
|
504
|
+
}
|
|
505
|
+
return Promise.resolve({});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const result: OnCallLeaveCleanupResult =
|
|
509
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
510
|
+
projectId: PROJECT_ID,
|
|
511
|
+
userId: USER_ID,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
expect(result.refreshedScheduleIds).toEqual(["schedule-2"]);
|
|
515
|
+
expect(spies.propagate).toHaveBeenCalledTimes(1);
|
|
516
|
+
expect(result.personalFeedDisabled).toBe(true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("a failing feed disable, reminder delete and rotation are each logged and the result reflects it", async () => {
|
|
520
|
+
const spies: Spies = stubWorld();
|
|
521
|
+
spies.feedUpdateOneBy.mockRejectedValue(new Error("db down"));
|
|
522
|
+
spies.remindersDeleteBy.mockRejectedValue(new Error("db down"));
|
|
523
|
+
spies.rotateScheduleFeeds.mockRejectedValue(new Error("db down"));
|
|
524
|
+
|
|
525
|
+
const result: OnCallLeaveCleanupResult =
|
|
526
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
527
|
+
projectId: PROJECT_ID,
|
|
528
|
+
userId: USER_ID,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(result.personalFeedDisabled).toBe(false);
|
|
532
|
+
expect(result.removedReminderCount).toBe(0);
|
|
533
|
+
expect(result.rotatedScheduleFeedIds).toEqual([]);
|
|
534
|
+
// The project feed rotation ran regardless of the schedule-feed failure.
|
|
535
|
+
expect(spies.rotateProjectFeeds).toHaveBeenCalledTimes(1);
|
|
536
|
+
expect(result.removedLayerUserCount).toBe(3);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("a failing override delete is logged and the rest of the cleanup still runs", async () => {
|
|
540
|
+
const spies: Spies = stubWorld();
|
|
541
|
+
spies.overridesDeleteBy.mockRejectedValue(new Error("db down"));
|
|
542
|
+
|
|
543
|
+
const result: OnCallLeaveCleanupResult =
|
|
544
|
+
await TeamMemberService.cleanupOnCallAssignmentsForUserLeavingProject({
|
|
545
|
+
projectId: PROJECT_ID,
|
|
546
|
+
userId: USER_ID,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(result.removedUserOverrideCount).toBe(0);
|
|
550
|
+
expect(result.removedLayerUserCount).toBe(3);
|
|
551
|
+
expect(result.refreshedScheduleIds).toEqual(["schedule-1", "schedule-2"]);
|
|
552
|
+
expect(result.personalFeedDisabled).toBe(true);
|
|
553
|
+
expect(logger.error).toHaveBeenCalled();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("the membership count failing makes the guard a no-op rather than an exception", async () => {
|
|
557
|
+
const spies: Spies = stubWorld();
|
|
558
|
+
spies.countMembers.mockRejectedValue(new Error("db down"));
|
|
559
|
+
|
|
560
|
+
await expect(
|
|
561
|
+
TeamMemberService.cleanupOnCallAssignmentsIfUserLeftProject({
|
|
562
|
+
projectId: PROJECT_ID,
|
|
563
|
+
userId: USER_ID,
|
|
564
|
+
}),
|
|
565
|
+
).resolves.toBeNull();
|
|
566
|
+
|
|
567
|
+
expect(spies.layerUsersDeleteBy).not.toHaveBeenCalled();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("a revoked invitation that was never accepted", () => {
|
|
572
|
+
/*
|
|
573
|
+
* Revoking a pending invite also brings the accepted-membership count to
|
|
574
|
+
* zero, so the guard fires. The row cleanup is harmless (there is nothing
|
|
575
|
+
* to remove), but rotating every opted-in shared feed would silently clear
|
|
576
|
+
* each subscribed teammate's calendar for someone who never had the link.
|
|
577
|
+
*/
|
|
578
|
+
test("cleans up rows but rotates NO shared feed", async () => {
|
|
579
|
+
const spies: Spies = stubWorld();
|
|
580
|
+
|
|
581
|
+
const result: OnCallLeaveCleanupResult | null =
|
|
582
|
+
await TeamMemberService.cleanupOnCallAssignmentsIfUserLeftProject({
|
|
583
|
+
projectId: PROJECT_ID,
|
|
584
|
+
userId: USER_ID,
|
|
585
|
+
hadAcceptedMembership: false,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
expect(result).not.toBeNull();
|
|
589
|
+
expect(spies.layerUsersDeleteBy).toHaveBeenCalledTimes(1);
|
|
590
|
+
expect(spies.remindersDeleteBy).toHaveBeenCalledTimes(1);
|
|
591
|
+
expect(spies.feedUpdateOneBy).toHaveBeenCalledTimes(1);
|
|
592
|
+
|
|
593
|
+
expect(spies.rotateScheduleFeeds).not.toHaveBeenCalled();
|
|
594
|
+
expect(spies.rotateProjectFeeds).not.toHaveBeenCalled();
|
|
595
|
+
expect(spies.purgeForProject).not.toHaveBeenCalled();
|
|
596
|
+
expect(result!.rotatedScheduleFeedIds).toEqual([]);
|
|
597
|
+
expect(result!.rotatedProjectFeedIds).toEqual([]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("an accepted membership (the default) still rotates", async () => {
|
|
601
|
+
const spies: Spies = stubWorld();
|
|
602
|
+
|
|
603
|
+
await TeamMemberService.cleanupOnCallAssignmentsIfUserLeftProject({
|
|
604
|
+
projectId: PROJECT_ID,
|
|
605
|
+
userId: USER_ID,
|
|
606
|
+
hadAcceptedMembership: true,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(spies.rotateScheduleFeeds).toHaveBeenCalledTimes(1);
|
|
610
|
+
expect(spies.rotateProjectFeeds).toHaveBeenCalledTimes(1);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe("onDeleteSuccess wiring", () => {
|
|
615
|
+
test("runs the cleanup once per (user, project) BEFORE the notification settings are removed", async () => {
|
|
616
|
+
jest
|
|
617
|
+
.spyOn(TeamMemberService, "refreshTokens")
|
|
618
|
+
.mockResolvedValue(undefined);
|
|
619
|
+
jest
|
|
620
|
+
.spyOn(
|
|
621
|
+
TeamMemberService,
|
|
622
|
+
"updateSubscriptionSeatsByUniqueTeamMembersInProject",
|
|
623
|
+
)
|
|
624
|
+
.mockResolvedValue(undefined);
|
|
625
|
+
const removeSettings: any = jest
|
|
626
|
+
.spyOn(
|
|
627
|
+
UserNotificationSettingService,
|
|
628
|
+
"removeDefaultNotificationSettingsForUser",
|
|
629
|
+
)
|
|
630
|
+
.mockResolvedValue(undefined);
|
|
631
|
+
const cleanup: any = jest
|
|
632
|
+
.spyOn(TeamMemberService, "cleanupOnCallAssignmentsIfUserLeftProject")
|
|
633
|
+
.mockResolvedValue(null);
|
|
634
|
+
|
|
635
|
+
const onDelete: any = {
|
|
636
|
+
deleteBy: { query: {}, props: { isRoot: true } },
|
|
637
|
+
carryForward: [
|
|
638
|
+
{
|
|
639
|
+
userId: USER_ID,
|
|
640
|
+
projectId: PROJECT_ID,
|
|
641
|
+
teamId: new ObjectID("t1"),
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
userId: USER_ID,
|
|
645
|
+
projectId: PROJECT_ID,
|
|
646
|
+
teamId: new ObjectID("t2"),
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
userId: new ObjectID("user-2"),
|
|
650
|
+
projectId: PROJECT_ID,
|
|
651
|
+
teamId: new ObjectID("t1"),
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
await (TeamMemberService as any).onDeleteSuccess(onDelete);
|
|
657
|
+
|
|
658
|
+
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
659
|
+
expect(
|
|
660
|
+
cleanup.mock.calls.map((c: Array<any>) => {
|
|
661
|
+
return c[0].userId.toString();
|
|
662
|
+
}),
|
|
663
|
+
).toEqual(["user-1", "user-2"]);
|
|
664
|
+
expect(cleanup.mock.calls[0]![0].projectId).toBe(PROJECT_ID);
|
|
665
|
+
|
|
666
|
+
// The settings are removed for every membership row, after the cleanup.
|
|
667
|
+
expect(removeSettings).toHaveBeenCalledTimes(3);
|
|
668
|
+
expect(cleanup.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
669
|
+
removeSettings.mock.invocationCallOrder[0]!,
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("hadAcceptedMembership is true only when one of the deleted rows was accepted", async () => {
|
|
674
|
+
jest
|
|
675
|
+
.spyOn(TeamMemberService, "refreshTokens")
|
|
676
|
+
.mockResolvedValue(undefined);
|
|
677
|
+
jest
|
|
678
|
+
.spyOn(
|
|
679
|
+
TeamMemberService,
|
|
680
|
+
"updateSubscriptionSeatsByUniqueTeamMembersInProject",
|
|
681
|
+
)
|
|
682
|
+
.mockResolvedValue(undefined);
|
|
683
|
+
jest
|
|
684
|
+
.spyOn(
|
|
685
|
+
UserNotificationSettingService,
|
|
686
|
+
"removeDefaultNotificationSettingsForUser",
|
|
687
|
+
)
|
|
688
|
+
.mockResolvedValue(undefined);
|
|
689
|
+
const cleanup: any = jest
|
|
690
|
+
.spyOn(TeamMemberService, "cleanupOnCallAssignmentsIfUserLeftProject")
|
|
691
|
+
.mockResolvedValue(null);
|
|
692
|
+
|
|
693
|
+
await (TeamMemberService as any).onDeleteSuccess({
|
|
694
|
+
deleteBy: { query: {}, props: { isRoot: true } },
|
|
695
|
+
carryForward: [
|
|
696
|
+
// user-1: left two teams, one of them a real (accepted) membership.
|
|
697
|
+
{
|
|
698
|
+
userId: USER_ID,
|
|
699
|
+
projectId: PROJECT_ID,
|
|
700
|
+
teamId: new ObjectID("t1"),
|
|
701
|
+
hasAcceptedInvitation: false,
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
userId: USER_ID,
|
|
705
|
+
projectId: PROJECT_ID,
|
|
706
|
+
teamId: new ObjectID("t2"),
|
|
707
|
+
hasAcceptedInvitation: true,
|
|
708
|
+
},
|
|
709
|
+
// user-2: a revoked invitation, never accepted.
|
|
710
|
+
{
|
|
711
|
+
userId: new ObjectID("user-2"),
|
|
712
|
+
projectId: PROJECT_ID,
|
|
713
|
+
teamId: new ObjectID("t1"),
|
|
714
|
+
hasAcceptedInvitation: false,
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
720
|
+
expect(cleanup.mock.calls[0]![0]).toMatchObject({
|
|
721
|
+
userId: USER_ID,
|
|
722
|
+
hadAcceptedMembership: true,
|
|
723
|
+
});
|
|
724
|
+
expect(cleanup.mock.calls[1]![0].userId.toString()).toBe("user-2");
|
|
725
|
+
expect(cleanup.mock.calls[1]![0].hadAcceptedMembership).toBe(false);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("the guard runs the real countBy rule when invoked through onDeleteSuccess", async () => {
|
|
729
|
+
jest
|
|
730
|
+
.spyOn(TeamMemberService, "refreshTokens")
|
|
731
|
+
.mockResolvedValue(undefined);
|
|
732
|
+
jest
|
|
733
|
+
.spyOn(
|
|
734
|
+
TeamMemberService,
|
|
735
|
+
"updateSubscriptionSeatsByUniqueTeamMembersInProject",
|
|
736
|
+
)
|
|
737
|
+
.mockResolvedValue(undefined);
|
|
738
|
+
jest
|
|
739
|
+
.spyOn(
|
|
740
|
+
UserNotificationSettingService,
|
|
741
|
+
"removeDefaultNotificationSettingsForUser",
|
|
742
|
+
)
|
|
743
|
+
.mockResolvedValue(undefined);
|
|
744
|
+
const spies: Spies = stubWorld({ remainingMemberships: 2 });
|
|
745
|
+
|
|
746
|
+
await (TeamMemberService as any).onDeleteSuccess({
|
|
747
|
+
deleteBy: { query: {}, props: {} },
|
|
748
|
+
carryForward: [{ userId: USER_ID, projectId: PROJECT_ID }],
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
expect(spies.countMembers).toHaveBeenCalledTimes(1);
|
|
752
|
+
expect(spies.layerUsersDeleteBy).not.toHaveBeenCalled();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
});
|