@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,1103 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { afterEach, describe, expect, jest, test } from "@jest/globals";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* PasswordHash carries a pre-existing TS5.9 diagnostic that fails any suite
|
|
7
|
+
* whose runtime require graph reaches it, and DatabaseService - the base class
|
|
8
|
+
* of every service below - imports it.
|
|
9
|
+
*/
|
|
10
|
+
jest.mock("../../../Server/Utils/PasswordHash", () => {
|
|
11
|
+
return {
|
|
12
|
+
__esModule: true,
|
|
13
|
+
default: {
|
|
14
|
+
hash: jest.fn(),
|
|
15
|
+
verify: jest.fn(),
|
|
16
|
+
generateSalt: jest.fn(),
|
|
17
|
+
needsUpgrade: jest.fn(),
|
|
18
|
+
applyPepper: jest.fn(),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
import UserOnCallCalendarFeedService from "../../../Server/Services/UserOnCallCalendarFeedService";
|
|
24
|
+
import OnCallDutyPolicyScheduleCalendarFeedService, {
|
|
25
|
+
MAX_MINIMUM_GAP_MINUTES,
|
|
26
|
+
MIN_MINIMUM_GAP_MINUTES,
|
|
27
|
+
} from "../../../Server/Services/OnCallDutyPolicyScheduleCalendarFeedService";
|
|
28
|
+
import ProjectOnCallCalendarFeedService from "../../../Server/Services/ProjectOnCallCalendarFeedService";
|
|
29
|
+
import OnCallDutyPolicyScheduleService from "../../../Server/Services/OnCallDutyPolicyScheduleService";
|
|
30
|
+
import UserOnCallCalendarFeed from "../../../Models/DatabaseModels/UserOnCallCalendarFeed";
|
|
31
|
+
import OnCallDutyPolicyScheduleCalendarFeed from "../../../Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed";
|
|
32
|
+
import ProjectOnCallCalendarFeed from "../../../Models/DatabaseModels/ProjectOnCallCalendarFeed";
|
|
33
|
+
import OnCallDutyPolicySchedule from "../../../Models/DatabaseModels/OnCallDutyPolicySchedule";
|
|
34
|
+
import CalendarFeedToken, {
|
|
35
|
+
CalendarFeedRotation,
|
|
36
|
+
} from "../../../Server/Utils/OnCall/CalendarFeedToken";
|
|
37
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
38
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
39
|
+
import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
|
|
40
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
41
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
42
|
+
import NotFoundException from "../../../Types/Exception/NotFoundException";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_FUTURE_DAYS,
|
|
45
|
+
DEFAULT_PAST_DAYS,
|
|
46
|
+
MAX_FUTURE_DAYS,
|
|
47
|
+
MAX_PAST_DAYS,
|
|
48
|
+
MIN_FUTURE_DAYS,
|
|
49
|
+
PREVIOUS_TOKEN_GRACE_DAYS,
|
|
50
|
+
} from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
|
|
51
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
52
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
53
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
54
|
+
import { getJestSpyOn } from "../../Spy";
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Fixture overrides may set a column to undefined on purpose (a missing
|
|
58
|
+
* field is the case under test), which Partial<Model> forbids under
|
|
59
|
+
* exactOptionalPropertyTypes.
|
|
60
|
+
*/
|
|
61
|
+
type Loose<TModel> = { [K in keyof TModel]?: TModel[K] | undefined };
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* The three calendar-feed services share one contract: the token is minted
|
|
65
|
+
* server-side and never chosen by a request, the window is clamped on every
|
|
66
|
+
* write, uniqueness violations become messages, and rotation keeps the old
|
|
67
|
+
* hash in a 30-day grace slot. The hooks are protected, so they are invoked
|
|
68
|
+
* through a narrowed view of the singleton (the ThreatIntelFeedService test
|
|
69
|
+
* discipline); every database call underneath is spied.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
const SERVICES_DIRECTORY: string = path.join(
|
|
73
|
+
__dirname,
|
|
74
|
+
"..",
|
|
75
|
+
"..",
|
|
76
|
+
"..",
|
|
77
|
+
"Server",
|
|
78
|
+
"Services",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const SERVICES_INDEX: string = fs.readFileSync(
|
|
82
|
+
path.join(SERVICES_DIRECTORY, "Index.ts"),
|
|
83
|
+
"utf8",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const NEW_SERVICE_NAMES: Array<string> = [
|
|
87
|
+
"UserOnCallCalendarFeedService",
|
|
88
|
+
"OnCallDutyPolicyScheduleCalendarFeedService",
|
|
89
|
+
"ProjectOnCallCalendarFeedService",
|
|
90
|
+
"UserOnCallShiftReminderService",
|
|
91
|
+
"UserOnCallShiftReminderLogService",
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
95
|
+
"11111111-1111-4111-8111-111111111111",
|
|
96
|
+
);
|
|
97
|
+
const OTHER_PROJECT_ID: ObjectID = new ObjectID(
|
|
98
|
+
"22222222-2222-4222-8222-222222222222",
|
|
99
|
+
);
|
|
100
|
+
const USER_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
|
|
101
|
+
const SCHEDULE_ID: ObjectID = new ObjectID(
|
|
102
|
+
"44444444-4444-4444-8444-444444444444",
|
|
103
|
+
);
|
|
104
|
+
const FEED_ID: ObjectID = new ObjectID("55555555-5555-4555-8555-555555555555");
|
|
105
|
+
const FEED_ID_2: ObjectID = new ObjectID(
|
|
106
|
+
"66666666-6666-4666-8666-666666666666",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const DAY_MS: number = 24 * 60 * 60 * 1000;
|
|
110
|
+
|
|
111
|
+
type HookCaller<TModel extends BaseModel> = {
|
|
112
|
+
onBeforeCreate: (createBy: CreateBy<TModel>) => Promise<OnCreate<TModel>>;
|
|
113
|
+
onBeforeUpdate: (updateBy: UpdateBy<TModel>) => Promise<OnUpdate<TModel>>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const userFeedHooks: HookCaller<UserOnCallCalendarFeed> =
|
|
117
|
+
UserOnCallCalendarFeedService as unknown as HookCaller<UserOnCallCalendarFeed>;
|
|
118
|
+
|
|
119
|
+
const scheduleFeedHooks: HookCaller<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
120
|
+
OnCallDutyPolicyScheduleCalendarFeedService as unknown as HookCaller<OnCallDutyPolicyScheduleCalendarFeed>;
|
|
121
|
+
|
|
122
|
+
const projectFeedHooks: HookCaller<ProjectOnCallCalendarFeed> =
|
|
123
|
+
ProjectOnCallCalendarFeedService as unknown as HookCaller<ProjectOnCallCalendarFeed>;
|
|
124
|
+
|
|
125
|
+
function rootCreate<TModel extends BaseModel>(
|
|
126
|
+
data: TModel,
|
|
127
|
+
props: Record<string, unknown> = {},
|
|
128
|
+
): CreateBy<TModel> {
|
|
129
|
+
return {
|
|
130
|
+
data,
|
|
131
|
+
props: { isRoot: true, ...props },
|
|
132
|
+
} as unknown as CreateBy<TModel>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function userCreate<TModel extends BaseModel>(
|
|
136
|
+
data: TModel,
|
|
137
|
+
props: Record<string, unknown> = {},
|
|
138
|
+
): CreateBy<TModel> {
|
|
139
|
+
return {
|
|
140
|
+
data,
|
|
141
|
+
props: {
|
|
142
|
+
isRoot: false,
|
|
143
|
+
userId: USER_ID,
|
|
144
|
+
tenantId: PROJECT_ID,
|
|
145
|
+
...props,
|
|
146
|
+
},
|
|
147
|
+
} as unknown as CreateBy<TModel>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function update<TModel extends BaseModel>(
|
|
151
|
+
data: Loose<TModel>,
|
|
152
|
+
isRoot: boolean = false,
|
|
153
|
+
): UpdateBy<TModel> {
|
|
154
|
+
return {
|
|
155
|
+
query: { _id: FEED_ID },
|
|
156
|
+
data,
|
|
157
|
+
props: { isRoot },
|
|
158
|
+
limit: 1,
|
|
159
|
+
skip: 0,
|
|
160
|
+
} as unknown as UpdateBy<TModel>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function expectConsistentTokenColumns(data: {
|
|
164
|
+
token?: string | undefined;
|
|
165
|
+
tokenHash?: string | undefined;
|
|
166
|
+
tokenHint?: string | undefined;
|
|
167
|
+
rotatedAt?: Date | undefined;
|
|
168
|
+
previousTokenHash?: string | undefined;
|
|
169
|
+
previousTokenExpiresAt?: Date | undefined;
|
|
170
|
+
}): void {
|
|
171
|
+
expect(CalendarFeedToken.isValidShape(data.token)).toBe(true);
|
|
172
|
+
expect(data.tokenHash).toBe(CalendarFeedToken.hash(data.token as string));
|
|
173
|
+
expect(data.tokenHint).toBe(CalendarFeedToken.hint(data.token as string));
|
|
174
|
+
expect(data.rotatedAt).toBeInstanceOf(Date);
|
|
175
|
+
expect(data.previousTokenHash).toBeUndefined();
|
|
176
|
+
expect(data.previousTokenExpiresAt).toBeUndefined();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
jest.restoreAllMocks();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("Service wiring", () => {
|
|
184
|
+
test.each(NEW_SERVICE_NAMES)("%s exists on disk", (serviceName: string) => {
|
|
185
|
+
expect(
|
|
186
|
+
fs.existsSync(path.join(SERVICES_DIRECTORY, `${serviceName}.ts`)),
|
|
187
|
+
).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test.each(NEW_SERVICE_NAMES)(
|
|
191
|
+
"%s is registered in Server/Services/Index.ts",
|
|
192
|
+
(serviceName: string) => {
|
|
193
|
+
/*
|
|
194
|
+
* That array is what boot-time createTables() iterates. An unregistered
|
|
195
|
+
* service has no table, however correct the model is.
|
|
196
|
+
*/
|
|
197
|
+
expect(SERVICES_INDEX).toContain(
|
|
198
|
+
`import ${serviceName} from "./${serviceName}";`,
|
|
199
|
+
);
|
|
200
|
+
expect(SERVICES_INDEX).toMatch(
|
|
201
|
+
new RegExp(`^\\s{2}${serviceName},$`, "m"),
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
test("registers each new service exactly once", () => {
|
|
207
|
+
for (const serviceName of NEW_SERVICE_NAMES) {
|
|
208
|
+
const occurrences: number = (
|
|
209
|
+
SERVICES_INDEX.match(new RegExp(`^\\s{2}${serviceName},$`, "gm")) || []
|
|
210
|
+
).length;
|
|
211
|
+
|
|
212
|
+
expect({ serviceName, occurrences }).toEqual({
|
|
213
|
+
serviceName,
|
|
214
|
+
occurrences: 1,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("UserOnCallCalendarFeedService.onBeforeCreate", () => {
|
|
221
|
+
function feed(
|
|
222
|
+
overrides: Loose<UserOnCallCalendarFeed> = {},
|
|
223
|
+
): UserOnCallCalendarFeed {
|
|
224
|
+
const model: UserOnCallCalendarFeed = new UserOnCallCalendarFeed();
|
|
225
|
+
model.projectId = PROJECT_ID;
|
|
226
|
+
model.userId = USER_ID;
|
|
227
|
+
Object.assign(model, overrides);
|
|
228
|
+
return model;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test("refuses a non-root create even with a valid session", async () => {
|
|
232
|
+
await expect(
|
|
233
|
+
userFeedHooks.onBeforeCreate(userCreate(feed())),
|
|
234
|
+
).rejects.toThrow(NotAuthorizedException);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("requires a project and a user", async () => {
|
|
238
|
+
await expect(
|
|
239
|
+
userFeedHooks.onBeforeCreate(rootCreate(feed({ projectId: undefined }))),
|
|
240
|
+
).rejects.toThrow(BadDataException);
|
|
241
|
+
|
|
242
|
+
await expect(
|
|
243
|
+
userFeedHooks.onBeforeCreate(rootCreate(feed({ userId: undefined }))),
|
|
244
|
+
).rejects.toThrow(BadDataException);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("takes the project from the tenant when the row does not carry one", async () => {
|
|
248
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
249
|
+
await userFeedHooks.onBeforeCreate(
|
|
250
|
+
rootCreate(feed({ projectId: undefined }), {
|
|
251
|
+
tenantId: OTHER_PROJECT_ID,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(onCreate.createBy.data.projectId).toBe(OTHER_PROJECT_ID);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("mints a consistent token set when none is supplied", async () => {
|
|
259
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
260
|
+
await userFeedHooks.onBeforeCreate(rootCreate(feed()));
|
|
261
|
+
|
|
262
|
+
expectConsistentTokenColumns(onCreate.createBy.data);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("derives hash and hint from a token the caller minted", async () => {
|
|
266
|
+
const token: string = CalendarFeedToken.mint();
|
|
267
|
+
|
|
268
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
269
|
+
await userFeedHooks.onBeforeCreate(
|
|
270
|
+
rootCreate(feed({ token, tokenHash: "not-the-hash", tokenHint: "zz" })),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(onCreate.createBy.data.token).toBe(token);
|
|
274
|
+
expect(onCreate.createBy.data.tokenHash).toBe(
|
|
275
|
+
CalendarFeedToken.hash(token),
|
|
276
|
+
);
|
|
277
|
+
expect(onCreate.createBy.data.tokenHint).toBe(
|
|
278
|
+
CalendarFeedToken.hint(token),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("replaces a malformed caller token and never trusts a bare hash", async () => {
|
|
283
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
284
|
+
await userFeedHooks.onBeforeCreate(
|
|
285
|
+
rootCreate(feed({ token: "too-short", tokenHash: "chosen-hash" })),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(onCreate.createBy.data.token).not.toBe("too-short");
|
|
289
|
+
expect(onCreate.createBy.data.tokenHash).not.toBe("chosen-hash");
|
|
290
|
+
expectConsistentTokenColumns(onCreate.createBy.data);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("never carries a grace-period hash into a brand-new row", async () => {
|
|
294
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
295
|
+
await userFeedHooks.onBeforeCreate(
|
|
296
|
+
rootCreate(
|
|
297
|
+
feed({
|
|
298
|
+
previousTokenHash: "stale",
|
|
299
|
+
previousTokenExpiresAt: new Date(),
|
|
300
|
+
}),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(onCreate.createBy.data.previousTokenHash).toBeUndefined();
|
|
305
|
+
expect(onCreate.createBy.data.previousTokenExpiresAt).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("applies the window defaults when nothing is supplied", async () => {
|
|
309
|
+
const onCreate: OnCreate<UserOnCallCalendarFeed> =
|
|
310
|
+
await userFeedHooks.onBeforeCreate(rootCreate(feed()));
|
|
311
|
+
|
|
312
|
+
expect(onCreate.createBy.data.pastDays).toBe(DEFAULT_PAST_DAYS);
|
|
313
|
+
expect(onCreate.createBy.data.futureDays).toBe(DEFAULT_FUTURE_DAYS);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("clamps the window into the CalendarFeedWindow bounds", async () => {
|
|
317
|
+
const tooBig: OnCreate<UserOnCallCalendarFeed> =
|
|
318
|
+
await userFeedHooks.onBeforeCreate(
|
|
319
|
+
rootCreate(feed({ pastDays: 999, futureDays: 9999 })),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(tooBig.createBy.data.pastDays).toBe(MAX_PAST_DAYS);
|
|
323
|
+
expect(tooBig.createBy.data.futureDays).toBe(MAX_FUTURE_DAYS);
|
|
324
|
+
|
|
325
|
+
const tooSmall: OnCreate<UserOnCallCalendarFeed> =
|
|
326
|
+
await userFeedHooks.onBeforeCreate(
|
|
327
|
+
rootCreate(feed({ pastDays: -5, futureDays: 1 })),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
expect(tooSmall.createBy.data.pastDays).toBe(0);
|
|
331
|
+
expect(tooSmall.createBy.data.futureDays).toBe(MIN_FUTURE_DAYS);
|
|
332
|
+
|
|
333
|
+
const numericStrings: OnCreate<UserOnCallCalendarFeed> =
|
|
334
|
+
await userFeedHooks.onBeforeCreate(
|
|
335
|
+
rootCreate(
|
|
336
|
+
feed({
|
|
337
|
+
pastDays: "10" as unknown as number,
|
|
338
|
+
futureDays: "45" as unknown as number,
|
|
339
|
+
}),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(numericStrings.createBy.data.pastDays).toBe(10);
|
|
344
|
+
expect(numericStrings.createBy.data.futureDays).toBe(45);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("UserOnCallCalendarFeedService.onBeforeUpdate", () => {
|
|
349
|
+
test("clamps pastDays and futureDays when present", async () => {
|
|
350
|
+
const onUpdate: OnUpdate<UserOnCallCalendarFeed> =
|
|
351
|
+
await userFeedHooks.onBeforeUpdate(
|
|
352
|
+
update<UserOnCallCalendarFeed>({ pastDays: 500, futureDays: 3 }),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(onUpdate.updateBy.data.pastDays).toBe(MAX_PAST_DAYS);
|
|
356
|
+
expect(onUpdate.updateBy.data.futureDays).toBe(MIN_FUTURE_DAYS);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("leaves an update that does not touch the window alone", async () => {
|
|
360
|
+
const onUpdate: OnUpdate<UserOnCallCalendarFeed> =
|
|
361
|
+
await userFeedHooks.onBeforeUpdate(
|
|
362
|
+
update<UserOnCallCalendarFeed>({ isEnabled: false }),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(onUpdate.updateBy.data).toEqual({ isEnabled: false });
|
|
366
|
+
expect(onUpdate.updateBy.data.pastDays).toBeUndefined();
|
|
367
|
+
expect(onUpdate.updateBy.data.futureDays).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("accepts in-range values unchanged", async () => {
|
|
371
|
+
const onUpdate: OnUpdate<UserOnCallCalendarFeed> =
|
|
372
|
+
await userFeedHooks.onBeforeUpdate(
|
|
373
|
+
update<UserOnCallCalendarFeed>({ pastDays: 7, futureDays: 120 }),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(onUpdate.updateBy.data.pastDays).toBe(7);
|
|
377
|
+
expect(onUpdate.updateBy.data.futureDays).toBe(120);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("UserOnCallCalendarFeedService.createForUser", () => {
|
|
382
|
+
test("creates as root with a freshly minted token and returns the plaintext once", async () => {
|
|
383
|
+
const create: jest.SpyInstance = getJestSpyOn(
|
|
384
|
+
UserOnCallCalendarFeedService,
|
|
385
|
+
"create",
|
|
386
|
+
).mockImplementation(async (createBy: CreateBy<UserOnCallCalendarFeed>) => {
|
|
387
|
+
const created: UserOnCallCalendarFeed = createBy.data;
|
|
388
|
+
created.id = FEED_ID;
|
|
389
|
+
return created;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const result: {
|
|
393
|
+
feed: UserOnCallCalendarFeed;
|
|
394
|
+
minted: { token: string; tokenHash: string; tokenHint: string };
|
|
395
|
+
} = await UserOnCallCalendarFeedService.createForUser({
|
|
396
|
+
projectId: PROJECT_ID,
|
|
397
|
+
userId: USER_ID,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
401
|
+
|
|
402
|
+
const createBy: CreateBy<UserOnCallCalendarFeed> = create.mock
|
|
403
|
+
.calls[0]![0] as CreateBy<UserOnCallCalendarFeed>;
|
|
404
|
+
|
|
405
|
+
expect(createBy.props.isRoot).toBe(true);
|
|
406
|
+
expect(createBy.data.projectId).toBe(PROJECT_ID);
|
|
407
|
+
expect(createBy.data.userId).toBe(USER_ID);
|
|
408
|
+
expect(createBy.data.token).toBe(result.minted.token);
|
|
409
|
+
expect(CalendarFeedToken.isValidShape(result.minted.token)).toBe(true);
|
|
410
|
+
expect(result.minted.tokenHash).toBe(
|
|
411
|
+
CalendarFeedToken.hash(result.minted.token),
|
|
412
|
+
);
|
|
413
|
+
expect(result.minted.tokenHint).toBe(
|
|
414
|
+
CalendarFeedToken.hint(result.minted.token),
|
|
415
|
+
);
|
|
416
|
+
expect(result.feed.id?.toString()).toBe(FEED_ID.toString());
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/*
|
|
421
|
+
* rotateTokenById has the same body in all three services; the table below
|
|
422
|
+
* drives one set of assertions over each so a divergence shows up by name.
|
|
423
|
+
*/
|
|
424
|
+
type RotatableService = {
|
|
425
|
+
findOneBy: (...args: Array<unknown>) => Promise<unknown>;
|
|
426
|
+
updateOneById: (...args: Array<unknown>) => Promise<number>;
|
|
427
|
+
rotateTokenById: (data: { id: ObjectID }) => Promise<CalendarFeedRotation>;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const ROTATABLE: Array<[string, RotatableService]> = [
|
|
431
|
+
[
|
|
432
|
+
"UserOnCallCalendarFeedService",
|
|
433
|
+
UserOnCallCalendarFeedService as unknown as RotatableService,
|
|
434
|
+
],
|
|
435
|
+
[
|
|
436
|
+
"OnCallDutyPolicyScheduleCalendarFeedService",
|
|
437
|
+
OnCallDutyPolicyScheduleCalendarFeedService as unknown as RotatableService,
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
"ProjectOnCallCalendarFeedService",
|
|
441
|
+
ProjectOnCallCalendarFeedService as unknown as RotatableService,
|
|
442
|
+
],
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
describe.each(ROTATABLE)(
|
|
446
|
+
"%s.rotateTokenById",
|
|
447
|
+
(_name: string, service: RotatableService) => {
|
|
448
|
+
test("mints a new token, parks the old hash for the grace period and stamps rotatedAt", async () => {
|
|
449
|
+
const before: number = Date.now();
|
|
450
|
+
|
|
451
|
+
getJestSpyOn(service, "findOneBy").mockResolvedValue({
|
|
452
|
+
id: FEED_ID,
|
|
453
|
+
_id: FEED_ID.toString(),
|
|
454
|
+
tokenHash: "old-hash",
|
|
455
|
+
});
|
|
456
|
+
const updateOneById: jest.SpyInstance = getJestSpyOn(
|
|
457
|
+
service,
|
|
458
|
+
"updateOneById",
|
|
459
|
+
).mockResolvedValue(1);
|
|
460
|
+
|
|
461
|
+
const rotation: CalendarFeedRotation = await service.rotateTokenById({
|
|
462
|
+
id: FEED_ID,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(updateOneById).toHaveBeenCalledTimes(1);
|
|
466
|
+
|
|
467
|
+
const call: {
|
|
468
|
+
id: ObjectID;
|
|
469
|
+
data: Record<string, unknown>;
|
|
470
|
+
props: { isRoot?: boolean };
|
|
471
|
+
} = updateOneById.mock.calls[0]![0] as {
|
|
472
|
+
id: ObjectID;
|
|
473
|
+
data: Record<string, unknown>;
|
|
474
|
+
props: { isRoot?: boolean };
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
expect(call.id).toBe(FEED_ID);
|
|
478
|
+
expect(call.props.isRoot).toBe(true);
|
|
479
|
+
|
|
480
|
+
expect(CalendarFeedToken.isValidShape(rotation.token)).toBe(true);
|
|
481
|
+
expect(rotation.tokenHash).toBe(CalendarFeedToken.hash(rotation.token));
|
|
482
|
+
expect(rotation.tokenHint).toBe(CalendarFeedToken.hint(rotation.token));
|
|
483
|
+
expect(rotation.tokenHash).not.toBe("old-hash");
|
|
484
|
+
expect(rotation.previousTokenHash).toBe("old-hash");
|
|
485
|
+
|
|
486
|
+
expect(call.data["token"]).toBe(rotation.token);
|
|
487
|
+
expect(call.data["tokenHash"]).toBe(rotation.tokenHash);
|
|
488
|
+
expect(call.data["tokenHint"]).toBe(rotation.tokenHint);
|
|
489
|
+
expect(call.data["previousTokenHash"]).toBe("old-hash");
|
|
490
|
+
expect(call.data["rotatedAt"]).toBe(rotation.rotatedAt);
|
|
491
|
+
|
|
492
|
+
const rotatedAt: number = (call.data["rotatedAt"] as Date).getTime();
|
|
493
|
+
expect(rotatedAt).toBeGreaterThanOrEqual(before);
|
|
494
|
+
expect(rotatedAt).toBeLessThanOrEqual(Date.now());
|
|
495
|
+
|
|
496
|
+
const expiresAt: number = (
|
|
497
|
+
call.data["previousTokenExpiresAt"] as Date
|
|
498
|
+
).getTime();
|
|
499
|
+
const expectedExpiry: number =
|
|
500
|
+
rotatedAt + PREVIOUS_TOKEN_GRACE_DAYS * DAY_MS;
|
|
501
|
+
// moment.add(days) is DST-aware; allow an hour either side.
|
|
502
|
+
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThanOrEqual(
|
|
503
|
+
60 * 60 * 1000,
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("reads only the hash (never the encrypted token) and as root without hooks", async () => {
|
|
508
|
+
const findOneBy: jest.SpyInstance = getJestSpyOn(
|
|
509
|
+
service,
|
|
510
|
+
"findOneBy",
|
|
511
|
+
).mockResolvedValue({ id: FEED_ID, tokenHash: "old-hash" });
|
|
512
|
+
getJestSpyOn(service, "updateOneById").mockResolvedValue(1);
|
|
513
|
+
|
|
514
|
+
await service.rotateTokenById({ id: FEED_ID });
|
|
515
|
+
|
|
516
|
+
const call: {
|
|
517
|
+
query: Record<string, unknown>;
|
|
518
|
+
select: Record<string, unknown>;
|
|
519
|
+
props: { isRoot?: boolean; ignoreHooks?: boolean };
|
|
520
|
+
} = findOneBy.mock.calls[0]![0] as {
|
|
521
|
+
query: Record<string, unknown>;
|
|
522
|
+
select: Record<string, unknown>;
|
|
523
|
+
props: { isRoot?: boolean; ignoreHooks?: boolean };
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
expect(call.query).toEqual({ _id: FEED_ID });
|
|
527
|
+
expect(call.select).toEqual({ _id: true, tokenHash: true });
|
|
528
|
+
expect(call.select["token"]).toBeUndefined();
|
|
529
|
+
expect(call.props.isRoot).toBe(true);
|
|
530
|
+
expect(call.props.ignoreHooks).toBe(true);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("a first mint on a row without a hash parks nothing", async () => {
|
|
534
|
+
getJestSpyOn(service, "findOneBy").mockResolvedValue({
|
|
535
|
+
id: FEED_ID,
|
|
536
|
+
tokenHash: undefined,
|
|
537
|
+
});
|
|
538
|
+
const updateOneById: jest.SpyInstance = getJestSpyOn(
|
|
539
|
+
service,
|
|
540
|
+
"updateOneById",
|
|
541
|
+
).mockResolvedValue(1);
|
|
542
|
+
|
|
543
|
+
const rotation: CalendarFeedRotation = await service.rotateTokenById({
|
|
544
|
+
id: FEED_ID,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
expect(rotation.previousTokenHash).toBeNull();
|
|
548
|
+
expect(rotation.previousTokenExpiresAt).toBeNull();
|
|
549
|
+
|
|
550
|
+
const data: Record<string, unknown> = (
|
|
551
|
+
updateOneById.mock.calls[0]![0] as { data: Record<string, unknown> }
|
|
552
|
+
).data;
|
|
553
|
+
|
|
554
|
+
expect(data["previousTokenHash"]).toBeNull();
|
|
555
|
+
expect(data["previousTokenExpiresAt"]).toBeNull();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("throws NotFound for a feed that does not exist and writes nothing", async () => {
|
|
559
|
+
getJestSpyOn(service, "findOneBy").mockResolvedValue(null);
|
|
560
|
+
const updateOneById: jest.SpyInstance = getJestSpyOn(
|
|
561
|
+
service,
|
|
562
|
+
"updateOneById",
|
|
563
|
+
).mockResolvedValue(1);
|
|
564
|
+
|
|
565
|
+
await expect(service.rotateTokenById({ id: FEED_ID })).rejects.toThrow(
|
|
566
|
+
NotFoundException,
|
|
567
|
+
);
|
|
568
|
+
expect(updateOneById).not.toHaveBeenCalled();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("two rotations never mint the same token", async () => {
|
|
572
|
+
getJestSpyOn(service, "findOneBy").mockResolvedValue({
|
|
573
|
+
id: FEED_ID,
|
|
574
|
+
tokenHash: "old-hash",
|
|
575
|
+
});
|
|
576
|
+
getJestSpyOn(service, "updateOneById").mockResolvedValue(1);
|
|
577
|
+
|
|
578
|
+
const first: CalendarFeedRotation = await service.rotateTokenById({
|
|
579
|
+
id: FEED_ID,
|
|
580
|
+
});
|
|
581
|
+
const second: CalendarFeedRotation = await service.rotateTokenById({
|
|
582
|
+
id: FEED_ID,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
expect(first.token).not.toBe(second.token);
|
|
586
|
+
expect(first.tokenHash).not.toBe(second.tokenHash);
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
describe("OnCallDutyPolicyScheduleCalendarFeedService.onBeforeCreate", () => {
|
|
592
|
+
function feed(
|
|
593
|
+
overrides: Loose<OnCallDutyPolicyScheduleCalendarFeed> = {},
|
|
594
|
+
): OnCallDutyPolicyScheduleCalendarFeed {
|
|
595
|
+
const model: OnCallDutyPolicyScheduleCalendarFeed =
|
|
596
|
+
new OnCallDutyPolicyScheduleCalendarFeed();
|
|
597
|
+
model.projectId = PROJECT_ID;
|
|
598
|
+
model.onCallDutyPolicyScheduleId = SCHEDULE_ID;
|
|
599
|
+
Object.assign(model, overrides);
|
|
600
|
+
return model;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function scheduleExists(exists: boolean): jest.SpyInstance {
|
|
604
|
+
return getJestSpyOn(
|
|
605
|
+
OnCallDutyPolicyScheduleService,
|
|
606
|
+
"findOneBy",
|
|
607
|
+
).mockResolvedValue(
|
|
608
|
+
exists ? ({ id: SCHEDULE_ID } as OnCallDutyPolicySchedule) : null,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function existingFeeds(count: number): jest.SpyInstance {
|
|
613
|
+
return getJestSpyOn(
|
|
614
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
615
|
+
"countBy",
|
|
616
|
+
).mockResolvedValue(new PositiveNumber(count));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
test("requires the schedule id", async () => {
|
|
620
|
+
scheduleExists(true);
|
|
621
|
+
existingFeeds(0);
|
|
622
|
+
|
|
623
|
+
await expect(
|
|
624
|
+
scheduleFeedHooks.onBeforeCreate(
|
|
625
|
+
userCreate(feed({ onCallDutyPolicyScheduleId: undefined })),
|
|
626
|
+
),
|
|
627
|
+
).rejects.toThrow(BadDataException);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("requires a project (row or tenant)", async () => {
|
|
631
|
+
scheduleExists(true);
|
|
632
|
+
existingFeeds(0);
|
|
633
|
+
|
|
634
|
+
await expect(
|
|
635
|
+
scheduleFeedHooks.onBeforeCreate(
|
|
636
|
+
userCreate(feed({ projectId: undefined }), { tenantId: undefined }),
|
|
637
|
+
),
|
|
638
|
+
).rejects.toThrow(BadDataException);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("refuses a schedule that is not in the caller's project", async () => {
|
|
642
|
+
const findOneBy: jest.SpyInstance = scheduleExists(false);
|
|
643
|
+
existingFeeds(0);
|
|
644
|
+
|
|
645
|
+
await expect(
|
|
646
|
+
scheduleFeedHooks.onBeforeCreate(userCreate(feed())),
|
|
647
|
+
).rejects.toThrow(BadDataException);
|
|
648
|
+
|
|
649
|
+
// The lookup is by (schedule, project) as root: the FK alone cannot tell.
|
|
650
|
+
const call: {
|
|
651
|
+
query: Record<string, unknown>;
|
|
652
|
+
props: { isRoot?: boolean };
|
|
653
|
+
} = findOneBy.mock.calls[0]![0] as {
|
|
654
|
+
query: Record<string, unknown>;
|
|
655
|
+
props: { isRoot?: boolean };
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
expect(call.query).toEqual({ _id: SCHEDULE_ID, projectId: PROJECT_ID });
|
|
659
|
+
expect(call.props.isRoot).toBe(true);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("scopes the schedule lookup to the tenant when the row has no projectId", async () => {
|
|
663
|
+
const findOneBy: jest.SpyInstance = scheduleExists(true);
|
|
664
|
+
existingFeeds(0);
|
|
665
|
+
|
|
666
|
+
const onCreate: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
667
|
+
await scheduleFeedHooks.onBeforeCreate(
|
|
668
|
+
userCreate(feed({ projectId: undefined }), {
|
|
669
|
+
tenantId: OTHER_PROJECT_ID,
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
expect(onCreate.createBy.data.projectId).toBe(OTHER_PROJECT_ID);
|
|
674
|
+
expect(
|
|
675
|
+
(findOneBy.mock.calls[0]![0] as { query: Record<string, unknown> }).query,
|
|
676
|
+
).toEqual({ _id: SCHEDULE_ID, projectId: OTHER_PROJECT_ID });
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("refuses a second feed for the same schedule with a readable message", async () => {
|
|
680
|
+
scheduleExists(true);
|
|
681
|
+
const countBy: jest.SpyInstance = existingFeeds(1);
|
|
682
|
+
|
|
683
|
+
await expect(
|
|
684
|
+
scheduleFeedHooks.onBeforeCreate(userCreate(feed())),
|
|
685
|
+
).rejects.toThrow(/already has a shared calendar feed/);
|
|
686
|
+
|
|
687
|
+
expect(
|
|
688
|
+
(countBy.mock.calls[0]![0] as { query: Record<string, unknown> }).query,
|
|
689
|
+
).toEqual({ onCallDutyPolicyScheduleId: SCHEDULE_ID });
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("mints the token for a non-root (permission-checked) publish", async () => {
|
|
693
|
+
scheduleExists(true);
|
|
694
|
+
existingFeeds(0);
|
|
695
|
+
|
|
696
|
+
const onCreate: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
697
|
+
await scheduleFeedHooks.onBeforeCreate(userCreate(feed()));
|
|
698
|
+
|
|
699
|
+
expectConsistentTokenColumns(onCreate.createBy.data);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("ignores a token a non-root request tried to choose", async () => {
|
|
703
|
+
scheduleExists(true);
|
|
704
|
+
existingFeeds(0);
|
|
705
|
+
|
|
706
|
+
const chosen: string = CalendarFeedToken.mint();
|
|
707
|
+
|
|
708
|
+
const onCreate: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
709
|
+
await scheduleFeedHooks.onBeforeCreate(
|
|
710
|
+
userCreate(
|
|
711
|
+
feed({
|
|
712
|
+
token: chosen,
|
|
713
|
+
tokenHash: CalendarFeedToken.hash(chosen),
|
|
714
|
+
tokenHint: CalendarFeedToken.hint(chosen),
|
|
715
|
+
}),
|
|
716
|
+
),
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
expect(onCreate.createBy.data.token).not.toBe(chosen);
|
|
720
|
+
expect(onCreate.createBy.data.tokenHash).not.toBe(
|
|
721
|
+
CalendarFeedToken.hash(chosen),
|
|
722
|
+
);
|
|
723
|
+
expectConsistentTokenColumns(onCreate.createBy.data);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("honours a token a ROOT caller minted (so the API can answer with the URL)", async () => {
|
|
727
|
+
scheduleExists(true);
|
|
728
|
+
existingFeeds(0);
|
|
729
|
+
|
|
730
|
+
const minted: string = CalendarFeedToken.mint();
|
|
731
|
+
|
|
732
|
+
const onCreate: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
733
|
+
await scheduleFeedHooks.onBeforeCreate(
|
|
734
|
+
rootCreate(feed({ token: minted })),
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
expect(onCreate.createBy.data.token).toBe(minted);
|
|
738
|
+
expect(onCreate.createBy.data.tokenHash).toBe(
|
|
739
|
+
CalendarFeedToken.hash(minted),
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("applies window defaults and clamps, and bounds the gap threshold", async () => {
|
|
744
|
+
scheduleExists(true);
|
|
745
|
+
existingFeeds(0);
|
|
746
|
+
|
|
747
|
+
const defaults: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
748
|
+
await scheduleFeedHooks.onBeforeCreate(userCreate(feed()));
|
|
749
|
+
|
|
750
|
+
expect(defaults.createBy.data.pastDays).toBe(DEFAULT_PAST_DAYS);
|
|
751
|
+
expect(defaults.createBy.data.futureDays).toBe(DEFAULT_FUTURE_DAYS);
|
|
752
|
+
expect(defaults.createBy.data.minimumGapMinutes).toBeUndefined();
|
|
753
|
+
|
|
754
|
+
const clamped: OnCreate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
755
|
+
await scheduleFeedHooks.onBeforeCreate(
|
|
756
|
+
userCreate(
|
|
757
|
+
feed({
|
|
758
|
+
pastDays: 400,
|
|
759
|
+
futureDays: 2,
|
|
760
|
+
minimumGapMinutes: "30" as unknown as number,
|
|
761
|
+
}),
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
expect(clamped.createBy.data.pastDays).toBe(MAX_PAST_DAYS);
|
|
766
|
+
expect(clamped.createBy.data.futureDays).toBe(MIN_FUTURE_DAYS);
|
|
767
|
+
expect(clamped.createBy.data.minimumGapMinutes).toBe(30);
|
|
768
|
+
|
|
769
|
+
await expect(
|
|
770
|
+
scheduleFeedHooks.onBeforeCreate(
|
|
771
|
+
userCreate(feed({ minimumGapMinutes: 0 })),
|
|
772
|
+
),
|
|
773
|
+
).rejects.toThrow(BadDataException);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
describe("OnCallDutyPolicyScheduleCalendarFeedService.onBeforeUpdate", () => {
|
|
778
|
+
test("clamps the window and validates the gap threshold when present", async () => {
|
|
779
|
+
const onUpdate: OnUpdate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
780
|
+
await scheduleFeedHooks.onBeforeUpdate(
|
|
781
|
+
update<OnCallDutyPolicyScheduleCalendarFeed>({
|
|
782
|
+
pastDays: -1,
|
|
783
|
+
futureDays: 1000,
|
|
784
|
+
minimumGapMinutes: 15,
|
|
785
|
+
}),
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
expect(onUpdate.updateBy.data.pastDays).toBe(0);
|
|
789
|
+
expect(onUpdate.updateBy.data.futureDays).toBe(MAX_FUTURE_DAYS);
|
|
790
|
+
expect(onUpdate.updateBy.data.minimumGapMinutes).toBe(15);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("rejects an out-of-range gap threshold", async () => {
|
|
794
|
+
await expect(
|
|
795
|
+
scheduleFeedHooks.onBeforeUpdate(
|
|
796
|
+
update<OnCallDutyPolicyScheduleCalendarFeed>({
|
|
797
|
+
minimumGapMinutes: MAX_MINIMUM_GAP_MINUTES + 1,
|
|
798
|
+
}),
|
|
799
|
+
),
|
|
800
|
+
).rejects.toThrow(BadDataException);
|
|
801
|
+
|
|
802
|
+
await expect(
|
|
803
|
+
scheduleFeedHooks.onBeforeUpdate(
|
|
804
|
+
update<OnCallDutyPolicyScheduleCalendarFeed>({
|
|
805
|
+
minimumGapMinutes: 2.5,
|
|
806
|
+
}),
|
|
807
|
+
),
|
|
808
|
+
).rejects.toThrow(BadDataException);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("leaves a settings-only update alone", async () => {
|
|
812
|
+
const onUpdate: OnUpdate<OnCallDutyPolicyScheduleCalendarFeed> =
|
|
813
|
+
await scheduleFeedHooks.onBeforeUpdate(
|
|
814
|
+
update<OnCallDutyPolicyScheduleCalendarFeed>({
|
|
815
|
+
includeCoverageGaps: true,
|
|
816
|
+
rotateWhenMemberLeaves: true,
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
expect(onUpdate.updateBy.data).toEqual({
|
|
821
|
+
includeCoverageGaps: true,
|
|
822
|
+
rotateWhenMemberLeaves: true,
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
describe("validateMinimumGapMinutes", () => {
|
|
828
|
+
const validators: Array<[string, (value: unknown) => number]> = [
|
|
829
|
+
[
|
|
830
|
+
"OnCallDutyPolicyScheduleCalendarFeedService",
|
|
831
|
+
(value: unknown): number => {
|
|
832
|
+
return (
|
|
833
|
+
OnCallDutyPolicyScheduleCalendarFeedService.constructor as unknown as {
|
|
834
|
+
validateMinimumGapMinutes: (value: unknown) => number;
|
|
835
|
+
}
|
|
836
|
+
).validateMinimumGapMinutes(value);
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
[
|
|
840
|
+
"ProjectOnCallCalendarFeedService",
|
|
841
|
+
(value: unknown): number => {
|
|
842
|
+
return (
|
|
843
|
+
ProjectOnCallCalendarFeedService.constructor as unknown as {
|
|
844
|
+
validateMinimumGapMinutes: (value: unknown) => number;
|
|
845
|
+
}
|
|
846
|
+
).validateMinimumGapMinutes(value);
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
];
|
|
850
|
+
|
|
851
|
+
test("publishes sensible bounds", () => {
|
|
852
|
+
expect(MIN_MINIMUM_GAP_MINUTES).toBe(1);
|
|
853
|
+
expect(MAX_MINIMUM_GAP_MINUTES).toBe(7 * 24 * 60);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test.each(validators)(
|
|
857
|
+
"%s accepts whole minutes inside the bounds, incl. numeric strings",
|
|
858
|
+
(_name: string, validate: (value: unknown) => number) => {
|
|
859
|
+
expect(validate(MIN_MINIMUM_GAP_MINUTES)).toBe(MIN_MINIMUM_GAP_MINUTES);
|
|
860
|
+
expect(validate(MAX_MINIMUM_GAP_MINUTES)).toBe(MAX_MINIMUM_GAP_MINUTES);
|
|
861
|
+
expect(validate(60)).toBe(60);
|
|
862
|
+
expect(validate("90")).toBe(90);
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
test.each(validators)(
|
|
867
|
+
"%s rejects everything else",
|
|
868
|
+
(_name: string, validate: (value: unknown) => number) => {
|
|
869
|
+
for (const bad of [
|
|
870
|
+
0,
|
|
871
|
+
-1,
|
|
872
|
+
MAX_MINIMUM_GAP_MINUTES + 1,
|
|
873
|
+
1.5,
|
|
874
|
+
Number.NaN,
|
|
875
|
+
Number.POSITIVE_INFINITY,
|
|
876
|
+
"",
|
|
877
|
+
"abc",
|
|
878
|
+
null,
|
|
879
|
+
undefined,
|
|
880
|
+
true,
|
|
881
|
+
{},
|
|
882
|
+
]) {
|
|
883
|
+
expect(() => {
|
|
884
|
+
return validate(bad);
|
|
885
|
+
}).toThrow(BadDataException);
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe("OnCallDutyPolicyScheduleCalendarFeedService.rotateFeedsForMemberLeave", () => {
|
|
892
|
+
test("rotates every enabled, opted-in feed of the project and returns their ids", async () => {
|
|
893
|
+
const findBy: jest.SpyInstance = getJestSpyOn(
|
|
894
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
895
|
+
"findBy",
|
|
896
|
+
).mockResolvedValue([{ id: FEED_ID }, { id: FEED_ID_2 }]);
|
|
897
|
+
const rotate: jest.SpyInstance = getJestSpyOn(
|
|
898
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
899
|
+
"rotateTokenById",
|
|
900
|
+
).mockResolvedValue({} as CalendarFeedRotation);
|
|
901
|
+
|
|
902
|
+
const rotated: Array<ObjectID> =
|
|
903
|
+
await OnCallDutyPolicyScheduleCalendarFeedService.rotateFeedsForMemberLeave(
|
|
904
|
+
{ projectId: PROJECT_ID },
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
expect(rotated).toEqual([FEED_ID, FEED_ID_2]);
|
|
908
|
+
expect(rotate).toHaveBeenCalledTimes(2);
|
|
909
|
+
expect(rotate).toHaveBeenNthCalledWith(1, { id: FEED_ID });
|
|
910
|
+
expect(rotate).toHaveBeenNthCalledWith(2, { id: FEED_ID_2 });
|
|
911
|
+
|
|
912
|
+
const call: {
|
|
913
|
+
query: Record<string, unknown>;
|
|
914
|
+
props: { isRoot?: boolean };
|
|
915
|
+
} = findBy.mock.calls[0]![0] as {
|
|
916
|
+
query: Record<string, unknown>;
|
|
917
|
+
props: { isRoot?: boolean };
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Disabled or un-flagged feeds are filtered at the query, not in memory.
|
|
921
|
+
expect(call.query).toEqual({
|
|
922
|
+
projectId: PROJECT_ID,
|
|
923
|
+
isEnabled: true,
|
|
924
|
+
rotateWhenMemberLeaves: true,
|
|
925
|
+
});
|
|
926
|
+
expect(call.props.isRoot).toBe(true);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("is a no-op when nothing opted in", async () => {
|
|
930
|
+
getJestSpyOn(
|
|
931
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
932
|
+
"findBy",
|
|
933
|
+
).mockResolvedValue([]);
|
|
934
|
+
const rotate: jest.SpyInstance = getJestSpyOn(
|
|
935
|
+
OnCallDutyPolicyScheduleCalendarFeedService,
|
|
936
|
+
"rotateTokenById",
|
|
937
|
+
).mockResolvedValue({} as CalendarFeedRotation);
|
|
938
|
+
|
|
939
|
+
await expect(
|
|
940
|
+
OnCallDutyPolicyScheduleCalendarFeedService.rotateFeedsForMemberLeave({
|
|
941
|
+
projectId: PROJECT_ID,
|
|
942
|
+
}),
|
|
943
|
+
).resolves.toEqual([]);
|
|
944
|
+
expect(rotate).not.toHaveBeenCalled();
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe("ProjectOnCallCalendarFeedService.onBeforeCreate", () => {
|
|
949
|
+
function feed(
|
|
950
|
+
overrides: Loose<ProjectOnCallCalendarFeed> = {},
|
|
951
|
+
): ProjectOnCallCalendarFeed {
|
|
952
|
+
const model: ProjectOnCallCalendarFeed = new ProjectOnCallCalendarFeed();
|
|
953
|
+
model.projectId = PROJECT_ID;
|
|
954
|
+
Object.assign(model, overrides);
|
|
955
|
+
return model;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function existingFeeds(count: number): jest.SpyInstance {
|
|
959
|
+
return getJestSpyOn(
|
|
960
|
+
ProjectOnCallCalendarFeedService,
|
|
961
|
+
"countBy",
|
|
962
|
+
).mockResolvedValue(new PositiveNumber(count));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
test("requires a project (row or tenant)", async () => {
|
|
966
|
+
existingFeeds(0);
|
|
967
|
+
|
|
968
|
+
await expect(
|
|
969
|
+
projectFeedHooks.onBeforeCreate(
|
|
970
|
+
userCreate(feed({ projectId: undefined }), { tenantId: undefined }),
|
|
971
|
+
),
|
|
972
|
+
).rejects.toThrow(BadDataException);
|
|
973
|
+
|
|
974
|
+
const onCreate: OnCreate<ProjectOnCallCalendarFeed> =
|
|
975
|
+
await projectFeedHooks.onBeforeCreate(
|
|
976
|
+
userCreate(feed({ projectId: undefined }), {
|
|
977
|
+
tenantId: OTHER_PROJECT_ID,
|
|
978
|
+
}),
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
expect(onCreate.createBy.data.projectId).toBe(OTHER_PROJECT_ID);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("refuses a second feed for the project", async () => {
|
|
985
|
+
const countBy: jest.SpyInstance = existingFeeds(1);
|
|
986
|
+
|
|
987
|
+
await expect(
|
|
988
|
+
projectFeedHooks.onBeforeCreate(userCreate(feed())),
|
|
989
|
+
).rejects.toThrow(/already has a shared calendar feed/);
|
|
990
|
+
|
|
991
|
+
expect(
|
|
992
|
+
(countBy.mock.calls[0]![0] as { query: Record<string, unknown> }).query,
|
|
993
|
+
).toEqual({ projectId: PROJECT_ID });
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("mints the token, ignoring one a non-root request chose", async () => {
|
|
997
|
+
existingFeeds(0);
|
|
998
|
+
|
|
999
|
+
const chosen: string = CalendarFeedToken.mint();
|
|
1000
|
+
|
|
1001
|
+
const onCreate: OnCreate<ProjectOnCallCalendarFeed> =
|
|
1002
|
+
await projectFeedHooks.onBeforeCreate(
|
|
1003
|
+
userCreate(feed({ token: chosen })),
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
expect(onCreate.createBy.data.token).not.toBe(chosen);
|
|
1007
|
+
expectConsistentTokenColumns(onCreate.createBy.data);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("applies defaults, clamps the window and bounds the gap threshold", async () => {
|
|
1011
|
+
existingFeeds(0);
|
|
1012
|
+
|
|
1013
|
+
const onCreate: OnCreate<ProjectOnCallCalendarFeed> =
|
|
1014
|
+
await projectFeedHooks.onBeforeCreate(
|
|
1015
|
+
userCreate(
|
|
1016
|
+
feed({ pastDays: 61, futureDays: 181, minimumGapMinutes: 120 }),
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
expect(onCreate.createBy.data.pastDays).toBe(MAX_PAST_DAYS);
|
|
1021
|
+
expect(onCreate.createBy.data.futureDays).toBe(MAX_FUTURE_DAYS);
|
|
1022
|
+
expect(onCreate.createBy.data.minimumGapMinutes).toBe(120);
|
|
1023
|
+
|
|
1024
|
+
await expect(
|
|
1025
|
+
projectFeedHooks.onBeforeCreate(
|
|
1026
|
+
userCreate(feed({ minimumGapMinutes: -1 })),
|
|
1027
|
+
),
|
|
1028
|
+
).rejects.toThrow(BadDataException);
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe("ProjectOnCallCalendarFeedService.onBeforeUpdate", () => {
|
|
1033
|
+
test("clamps and validates when present, leaves the rest alone", async () => {
|
|
1034
|
+
const onUpdate: OnUpdate<ProjectOnCallCalendarFeed> =
|
|
1035
|
+
await projectFeedHooks.onBeforeUpdate(
|
|
1036
|
+
update<ProjectOnCallCalendarFeed>({
|
|
1037
|
+
futureDays: 5,
|
|
1038
|
+
minimumGapMinutes: "45" as unknown as number,
|
|
1039
|
+
isEnabled: false,
|
|
1040
|
+
}),
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
expect(onUpdate.updateBy.data).toEqual({
|
|
1044
|
+
futureDays: MIN_FUTURE_DAYS,
|
|
1045
|
+
minimumGapMinutes: 45,
|
|
1046
|
+
isEnabled: false,
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
await expect(
|
|
1050
|
+
projectFeedHooks.onBeforeUpdate(
|
|
1051
|
+
update<ProjectOnCallCalendarFeed>({
|
|
1052
|
+
minimumGapMinutes: "x" as unknown as number,
|
|
1053
|
+
}),
|
|
1054
|
+
),
|
|
1055
|
+
).rejects.toThrow(BadDataException);
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
describe("ProjectOnCallCalendarFeedService.rotateFeedsForMemberLeave", () => {
|
|
1060
|
+
test("rotates the project's feed when it is enabled and opted in", async () => {
|
|
1061
|
+
const findOneBy: jest.SpyInstance = getJestSpyOn(
|
|
1062
|
+
ProjectOnCallCalendarFeedService,
|
|
1063
|
+
"findOneBy",
|
|
1064
|
+
).mockResolvedValue({ id: FEED_ID });
|
|
1065
|
+
const rotate: jest.SpyInstance = getJestSpyOn(
|
|
1066
|
+
ProjectOnCallCalendarFeedService,
|
|
1067
|
+
"rotateTokenById",
|
|
1068
|
+
).mockResolvedValue({} as CalendarFeedRotation);
|
|
1069
|
+
|
|
1070
|
+
await expect(
|
|
1071
|
+
ProjectOnCallCalendarFeedService.rotateFeedsForMemberLeave({
|
|
1072
|
+
projectId: PROJECT_ID,
|
|
1073
|
+
}),
|
|
1074
|
+
).resolves.toEqual([FEED_ID]);
|
|
1075
|
+
|
|
1076
|
+
expect(rotate).toHaveBeenCalledWith({ id: FEED_ID });
|
|
1077
|
+
expect(
|
|
1078
|
+
(findOneBy.mock.calls[0]![0] as { query: Record<string, unknown> }).query,
|
|
1079
|
+
).toEqual({
|
|
1080
|
+
projectId: PROJECT_ID,
|
|
1081
|
+
isEnabled: true,
|
|
1082
|
+
rotateWhenMemberLeaves: true,
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("is a no-op without an opted-in feed", async () => {
|
|
1087
|
+
getJestSpyOn(
|
|
1088
|
+
ProjectOnCallCalendarFeedService,
|
|
1089
|
+
"findOneBy",
|
|
1090
|
+
).mockResolvedValue(null);
|
|
1091
|
+
const rotate: jest.SpyInstance = getJestSpyOn(
|
|
1092
|
+
ProjectOnCallCalendarFeedService,
|
|
1093
|
+
"rotateTokenById",
|
|
1094
|
+
).mockResolvedValue({} as CalendarFeedRotation);
|
|
1095
|
+
|
|
1096
|
+
await expect(
|
|
1097
|
+
ProjectOnCallCalendarFeedService.rotateFeedsForMemberLeave({
|
|
1098
|
+
projectId: PROJECT_ID,
|
|
1099
|
+
}),
|
|
1100
|
+
).resolves.toEqual([]);
|
|
1101
|
+
expect(rotate).not.toHaveBeenCalled();
|
|
1102
|
+
});
|
|
1103
|
+
});
|