@oneuptime/common 12.0.29 → 12.0.31
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/AnalyticsModels/SecurityEvent.ts +48 -16
- package/Models/AnalyticsModels/ThreatIntelIndicator.ts +10 -5
- package/Models/DatabaseModels/AlertSeverity.ts +44 -0
- package/Models/DatabaseModels/DetectionRule.ts +16 -5
- package/Models/DatabaseModels/GoogleSecOpsConnection.ts +14 -5
- package/Models/DatabaseModels/IncidentSeverity.ts +44 -0
- package/Models/DatabaseModels/Index.ts +13 -0
- package/Models/DatabaseModels/NetworkDevice.ts +113 -3
- package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +88 -0
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +22 -0
- package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +499 -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/ThreatIntelFeed.ts +16 -5
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/GlobalConfigAPI.ts +362 -183
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/API/TelemetryAPI.ts +42 -4
- package/Server/EnvironmentConfig.ts +250 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Middleware/MultipartFormData.ts +118 -32
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/AlertEpisodeLabelRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodeOnCallRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodeOwnerRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodePrivacyRuleEngineService.ts +9 -1
- package/Server/Services/AlertGroupingEngineService.ts +9 -1
- package/Server/Services/AlertLabelRuleEngineService.ts +9 -1
- package/Server/Services/AlertOnCallRuleEngineService.ts +9 -1
- package/Server/Services/AlertOwnerRuleEngineService.ts +9 -1
- package/Server/Services/AlertPrivacyRuleEngineService.ts +9 -1
- package/Server/Services/AlertReminderRuleService.ts +9 -1
- package/Server/Services/AutoRemediationRuleEngineService.ts +9 -1
- package/Server/Services/CephClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/CephClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/CloudResourceLabelRuleEngineService.ts +9 -1
- package/Server/Services/CloudResourceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DashboardLabelRuleEngineService.ts +9 -1
- package/Server/Services/DashboardOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DockerHostLabelRuleEngineService.ts +9 -1
- package/Server/Services/DockerHostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/HostLabelRuleEngineService.ts +9 -1
- package/Server/Services/HostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeOnCallRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodePrivacyRuleEngineService.ts +9 -1
- package/Server/Services/IncidentGroupingEngineService.ts +9 -1
- package/Server/Services/IncidentLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncidentOnCallRuleEngineService.ts +9 -1
- package/Server/Services/IncidentOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentPrivacyRuleEngineService.ts +9 -1
- package/Server/Services/IncidentReminderRuleService.ts +9 -1
- package/Server/Services/IncidentSlaRuleService.ts +9 -1
- package/Server/Services/IncomingCallPolicyLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncomingCallPolicyOwnerRuleEngineService.ts +9 -1
- package/Server/Services/Index.ts +14 -0
- package/Server/Services/IoTFleetLabelRuleEngineService.ts +9 -1
- package/Server/Services/IoTFleetOwnerRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesResourceService.ts +5 -1
- package/Server/Services/MonitorLabelRuleEngineService.ts +9 -1
- package/Server/Services/MonitorOwnerRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +35 -0
- package/Server/Services/NetworkDeviceAutoImportRuleService.ts +96 -0
- package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceOidTemplateService.ts +201 -0
- package/Server/Services/NetworkDeviceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceService.ts +209 -1
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyLabelRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/PodmanHostLabelRuleEngineService.ts +9 -1
- package/Server/Services/PodmanHostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RumApplicationLabelRuleEngineService.ts +9 -1
- package/Server/Services/RumApplicationOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RunbookLabelRuleEngineService.ts +9 -1
- package/Server/Services/RunbookOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RunbookRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +9 -1
- package/Server/Services/ServerlessFunctionLabelRuleEngineService.ts +9 -1
- package/Server/Services/ServerlessFunctionOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ServiceLabelRuleEngineService.ts +9 -1
- package/Server/Services/ServiceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/StatusPageLabelRuleEngineService.ts +9 -1
- package/Server/Services/StatusPageMonitorRuleEngineService.ts +7 -0
- package/Server/Services/StatusPageOwnerRuleEngineService.ts +9 -1
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/TelemetrySourceMapService.ts +266 -23
- 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/Services/UserService.ts +27 -0
- package/Server/Services/WorkflowLabelRuleEngineService.ts +9 -1
- package/Server/Services/WorkflowOwnerRuleEngineService.ts +9 -1
- package/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.ts +186 -0
- package/Server/Utils/Monitor/CapturedMetricAttributeUtil.ts +245 -0
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +21 -2
- package/Server/Utils/Monitor/MonitorMetricUtil.ts +55 -21
- package/Server/Utils/Monitor/MonitorTemplateUtil.ts +32 -0
- package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +15 -2
- package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +18 -1
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +10 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +34 -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/Rules/RuleEngineRuleRead.ts +48 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Server/Utils/Telemetry/ExceptionSanitizer.ts +14 -1
- package/Server/Utils/Telemetry/LogExceptionExtractor.ts +14 -2
- package/Server/Utils/Telemetry/SourceMapResolver.ts +16 -5
- package/Server/Utils/Telemetry/StackTraceParser.ts +399 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +125 -0
- package/Tests/App/Dashboard/HeaderImageAssets.test.tsx +93 -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/OnCallReadinessSurfaces.test.tsx +3 -3
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserElementEmail.test.tsx +12 -4
- package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +1 -1
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +14 -0
- package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +469 -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/SecurityDomainAccessControl.test.ts +429 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/GlobalConfigLicense.test.ts +657 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +84 -6
- package/Tests/Server/API/TelemetryResolveStackTraceAPI.test.ts +805 -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 +394 -0
- package/Tests/Server/Middleware/IdentityRateLimit.test.ts +1144 -0
- package/Tests/Server/Middleware/MultipartFormData.test.ts +618 -3
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/MonitorLabelRuleEngineScale.test.ts +520 -0
- package/Tests/Server/Services/NetworkDeviceOidTemplateService.test.ts +332 -0
- package/Tests/Server/Services/NetworkDeviceOidTemplateWriteGuards.test.ts +409 -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/RuleEngineRuleFetchLimit.test.ts +239 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/TelemetrySourceMapService.test.ts +1210 -2
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Services/UserServiceSeatLimit.test.ts +182 -0
- package/Tests/Server/Types/AnalyticsDatabase/SecurityEventModelPermission.test.ts +255 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +212 -0
- package/Tests/Server/Types/Database/Permissions/SecurityRoleAccess.test.ts +413 -0
- package/Tests/Server/Utils/CodeRepository.test.ts +689 -0
- package/Tests/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.test.ts +495 -0
- package/Tests/Server/Utils/ExceptionSanitizer.test.ts +77 -0
- package/Tests/Server/Utils/Monitor/CapturedMetricAttributeUtil.test.ts +284 -0
- package/Tests/Server/Utils/Monitor/MonitorCustomMetricAttributes.test.ts +503 -0
- package/Tests/Server/Utils/Monitor/MonitorTemplatePrototypePollution.test.ts +106 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +33 -4
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +14 -4
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +44 -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/OpenAPISpec.test.ts +536 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/Rules/RuleEngineRuleRead.test.ts +115 -0
- package/Tests/Server/Utils/StackTraceParser.test.ts +610 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
- package/Tests/Server/Utils/Telemetry/SourceMapLimits.test.ts +656 -0
- package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +100 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/Monitor/SnmpMonitor/SnmpOidListUtil.test.ts +454 -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/Types/SecurityRolePermissions.test.ts +193 -0
- package/Tests/UI/Components/Charts/AnnotationLayout.test.ts +773 -0
- package/Tests/UI/Components/Charts/ChartAnnotationRail.test.tsx +789 -0
- package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +254 -0
- package/Tests/UI/Components/Charts/TimeAnnotation.test.ts +471 -0
- package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +405 -0
- package/Tests/UI/Components/ErrorMessageRefreshControl.test.tsx +83 -0
- package/Tests/UI/Components/MarkdownConverters.test.ts +686 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableDeleteAndEmptyStates.test.tsx +341 -0
- package/Tests/UI/Components/OrderedStatesList.test.tsx +5 -5
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +1 -1
- package/Tests/UI/Components/TableSortAndMobileColumns.test.tsx +195 -0
- package/Tests/UI/Components/XAxis.test.ts +270 -5
- package/Tests/UI/Components/XAxisDaylightSaving.test.ts +113 -0
- package/Tests/UI/Styles/ChartFocusRing.test.ts +153 -0
- package/Tests/UI/Styles/ChartFocusRingCoverage.test.tsx +377 -0
- package/Tests/UI/Styles/ThemeStylesheet.ts +162 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/Utils/EnterpriseLicenseSeats.test.ts +507 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +951 -1
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +238 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +1056 -0
- package/Tests/Utils/NetworkDiscovery/ReverseDnsNameUtil.test.ts +882 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Date.ts +39 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Monitor/SnmpMonitor/SnmpOidListUtil.ts +333 -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/Types/Permission.ts +96 -0
- package/Types/Rum/SessionReplay.ts +21 -0
- package/Types/Telemetry/SourceMap.ts +8 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.tsx +176 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.ts +745 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.tsx +576 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.tsx +204 -0
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +491 -544
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +324 -372
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +487 -540
- package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +7 -3
- package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +14 -3
- package/UI/Components/Charts/Types/ChartEventKind.ts +13 -0
- package/UI/Components/Charts/Types/ReferenceRegionProps.ts +6 -0
- package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +20 -3
- package/UI/Components/Charts/Utils/TimeAnnotation.ts +69 -35
- package/UI/Components/Charts/Utils/XAxis.ts +125 -18
- package/UI/Components/EditionLabel/EditionLabel.tsx +280 -26
- package/UI/Components/ErrorMessage/ErrorMessage.tsx +21 -10
- package/UI/Components/Feed/FeedItem.tsx +4 -5
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -2
- package/UI/Components/Header/UserProfile/UserProfile.tsx +1 -2
- package/UI/Components/Image/Image.tsx +20 -3
- package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
- package/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +1 -2
- package/UI/Components/ModelTable/BaseModelTable.tsx +150 -24
- package/UI/Components/Table/TableHeader.tsx +64 -48
- package/UI/Components/Table/TableRow.tsx +13 -3
- package/UI/Styles/Theme.css +38 -0
- package/Utils/EnterpriseLicense/EnterpriseLicenseSeats.ts +271 -0
- package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +6 -0
- package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +141 -10
- package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +55 -3
- package/Utils/NetworkDiscovery/ReverseDnsNameUtil.ts +151 -0
- package/Utils/Rules/RuleEngineLimits.ts +32 -0
- package/build/dist/Models/AnalyticsModels/SecurityEvent.js +48 -16
- package/build/dist/Models/AnalyticsModels/SecurityEvent.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +10 -5
- package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertSeverity.js +46 -1
- package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DetectionRule.js +16 -5
- package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +14 -5
- package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentSeverity.js +46 -1
- package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +13 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +115 -3
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +88 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +519 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -0
- 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/ThreatIntelFeed.js +16 -5
- package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -1
- 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/GlobalConfigAPI.js +293 -176
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/API/TelemetryAPI.js +31 -3
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +185 -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/1790500000000-AddNetworkDeviceOidTemplate.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MultipartFormData.js +93 -28
- package/build/dist/Server/Middleware/MultipartFormData.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/AlertEpisodeLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertGroupingEngineService.js +8 -1
- package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js +8 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DashboardLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DashboardLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/HostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/HostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentGroupingEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentReminderRuleService.js +8 -1
- package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSlaRuleService.js +8 -1
- package/build/dist/Server/Services/IncidentSlaRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +14 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesResourceService.js +5 -1
- package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
- package/build/dist/Server/Services/MonitorLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/MonitorLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +30 -21
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +64 -0
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js +180 -0
- package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +154 -1
- package/build/dist/Server/Services/NetworkDeviceService.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/OnCallDutyPolicyLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.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/OnCallDutyPolicyScheduleLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js.map +1 -1
- 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/OnCallDutyPolicyScheduleOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.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/PodmanHostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.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/ProxmoxClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +6 -0
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js.map +1 -1
- 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/TelemetrySourceMapService.js +213 -19
- package/build/dist/Server/Services/TelemetrySourceMapService.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/Services/UserService.js +25 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js +145 -0
- package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js +201 -0
- package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +19 -2
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +42 -23
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +29 -0
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +11 -2
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +16 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +10 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +33 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- 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/Rules/RuleEngineRuleRead.js +37 -0
- package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js.map +1 -0
- 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/Server/Utils/Telemetry/ExceptionSanitizer.js +12 -1
- package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +14 -2
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +15 -5
- package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +307 -0
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.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/Date.js +27 -0
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js +253 -0
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js.map +1 -0
- 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/build/dist/Types/Permission.js +86 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplay.js +19 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js +83 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js +531 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js +264 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js +122 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +213 -233
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +139 -152
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +212 -232
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Types/ChartEventKind.js +14 -0
- package/build/dist/UI/Components/Charts/Types/ChartEventKind.js.map +1 -0
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +57 -28
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/XAxis.js +103 -17
- package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +198 -23
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js +15 -5
- package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedItem.js +4 -5
- package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -2
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Header/UserProfile/UserProfile.js.map +1 -1
- package/build/dist/UI/Components/Image/Image.js +6 -1
- package/build/dist/UI/Components/Image/Image.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
- package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
- package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js +1 -2
- package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +104 -23
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +20 -22
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +13 -4
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js +153 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +127 -7
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +50 -3
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js +132 -0
- package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js.map +1 -0
- package/build/dist/Utils/Rules/RuleEngineLimits.js +31 -0
- package/build/dist/Utils/Rules/RuleEngineLimits.js.map +1 -0
- package/jest.config.json +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,2142 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The renderer behind the on-call calendar feeds: authorisation re-derived
|
|
5
|
+
* per fetch, the body / schedule-level / last-good caches, the render cap,
|
|
6
|
+
* stale-while-error, the iteration-cap and MAX_EVENTS truncations, coverage
|
|
7
|
+
* gaps, and the /my-shifts materialization.
|
|
8
|
+
*
|
|
9
|
+
* The materializer and every database read are stubbed at their public
|
|
10
|
+
* seams; the caches are REAL (Redis is mocked away, so they run on their
|
|
11
|
+
* in-process tier) because the properties under test -- a second fetch is a
|
|
12
|
+
* cache hit, a partial render never becomes "last good", the last good body
|
|
13
|
+
* outlives a failed render -- are about how the renderer drives them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type EnvMockGlobal = typeof globalThis & {
|
|
17
|
+
__oneuptimeCalendarRendererBillingEnabled: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
jest.mock("../../../../Server/EnvironmentConfig", () => {
|
|
21
|
+
const actual: Record<string, unknown> = jest.requireActual(
|
|
22
|
+
"../../../../Server/EnvironmentConfig",
|
|
23
|
+
) as Record<string, unknown>;
|
|
24
|
+
const mocked: Record<string, unknown> = { ...actual };
|
|
25
|
+
const mockGlobal: EnvMockGlobal = globalThis as EnvMockGlobal;
|
|
26
|
+
|
|
27
|
+
mockGlobal.__oneuptimeCalendarRendererBillingEnabled = false;
|
|
28
|
+
|
|
29
|
+
Object.defineProperty(mocked, "IsBillingEnabled", {
|
|
30
|
+
configurable: true,
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: (): boolean => {
|
|
33
|
+
return mockGlobal.__oneuptimeCalendarRendererBillingEnabled;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return mocked;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
jest.mock("../../../../Server/Utils/Logger", () => {
|
|
41
|
+
return {
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: {
|
|
44
|
+
debug: jest.fn(),
|
|
45
|
+
info: jest.fn(),
|
|
46
|
+
warn: jest.fn(),
|
|
47
|
+
error: jest.fn(),
|
|
48
|
+
},
|
|
49
|
+
getLogAttributesFromRequest: jest.fn().mockReturnValue({}),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
jest.mock("../../../../Server/Infrastructure/Redis", () => {
|
|
54
|
+
return {
|
|
55
|
+
__esModule: true,
|
|
56
|
+
default: {
|
|
57
|
+
getClient: jest.fn(() => {
|
|
58
|
+
return null;
|
|
59
|
+
}),
|
|
60
|
+
isConnected: jest.fn(() => {
|
|
61
|
+
return false;
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
jest.mock("../../../../Server/Utils/PasswordHash", () => {
|
|
68
|
+
return {
|
|
69
|
+
__esModule: true,
|
|
70
|
+
default: {
|
|
71
|
+
hash: jest.fn(),
|
|
72
|
+
verify: jest.fn(),
|
|
73
|
+
generateSalt: jest.fn(),
|
|
74
|
+
needsUpgrade: jest.fn(),
|
|
75
|
+
applyPepper: jest.fn(),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
import DatabaseConfig from "../../../../Server/DatabaseConfig";
|
|
81
|
+
import OnCallCalendarFeedCache from "../../../../Server/Infrastructure/OnCallCalendarFeedCache";
|
|
82
|
+
import OnCallDutyPolicyScheduleService from "../../../../Server/Services/OnCallDutyPolicyScheduleService";
|
|
83
|
+
import ProjectService from "../../../../Server/Services/ProjectService";
|
|
84
|
+
import UserService from "../../../../Server/Services/UserService";
|
|
85
|
+
import logger from "../../../../Server/Utils/Logger";
|
|
86
|
+
import OnCallCalendarFeedRenderer, {
|
|
87
|
+
CachedScheduleSegments,
|
|
88
|
+
FEED_DISABLED_REASON,
|
|
89
|
+
FILTERED_SCHEDULE_REASON,
|
|
90
|
+
FeedRenderOutcome,
|
|
91
|
+
FeedRenderRequest,
|
|
92
|
+
FeedRenderStatus,
|
|
93
|
+
NO_PROJECT_SCHEDULES_REASON,
|
|
94
|
+
NO_SCHEDULES_REASON,
|
|
95
|
+
PLAN_REASON,
|
|
96
|
+
PROJECT_MISSING_REASON,
|
|
97
|
+
RENDER_CAP_RETRY_AFTER_SECONDS,
|
|
98
|
+
SCHEDULE_MISSING_REASON,
|
|
99
|
+
USER_MISSING_REASON,
|
|
100
|
+
UserShiftsResult,
|
|
101
|
+
} from "../../../../Server/Utils/OnCall/OnCallCalendarFeedRenderer";
|
|
102
|
+
import OnCallShiftMaterializer, {
|
|
103
|
+
MaterializeResult,
|
|
104
|
+
} from "../../../../Server/Utils/OnCall/OnCallShiftMaterializer";
|
|
105
|
+
import Response from "../../../../Server/Utils/Response";
|
|
106
|
+
import AppMetrics from "../../../../Server/Utils/Telemetry/AppMetrics";
|
|
107
|
+
import SubscriptionPlan, {
|
|
108
|
+
PlanType,
|
|
109
|
+
} from "../../../../Types/Billing/SubscriptionPlan";
|
|
110
|
+
import EventInterval from "../../../../Types/Events/EventInterval";
|
|
111
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
112
|
+
import CalendarFeedWindow, {
|
|
113
|
+
MAX_EVENTS,
|
|
114
|
+
MAX_GAP_EVENTS,
|
|
115
|
+
} from "../../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
|
|
116
|
+
import { LayerProps } from "../../../../Types/OnCallDutyPolicy/Layer";
|
|
117
|
+
import { MaterializedShift } from "../../../../Types/OnCallDutyPolicy/MaterializedShift";
|
|
118
|
+
import { OnCallCalendarFeedKind } from "../../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
|
|
119
|
+
import {
|
|
120
|
+
DASHBOARD_URL,
|
|
121
|
+
at,
|
|
122
|
+
eventBlocks,
|
|
123
|
+
noRestriction,
|
|
124
|
+
property,
|
|
125
|
+
rotation,
|
|
126
|
+
shift,
|
|
127
|
+
user,
|
|
128
|
+
} from "../../../Types/OnCallDutyPolicy/CalendarFeedTestFixtures";
|
|
129
|
+
|
|
130
|
+
// -- Fixtures ---------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const NOW: Date = at("2026-09-01T12:00:00Z");
|
|
133
|
+
|
|
134
|
+
/* pastDays 2 / futureDays 7 around NOW (see CalendarFeedWindow). */
|
|
135
|
+
const FEED_START: Date = at("2026-08-30T00:00:00Z");
|
|
136
|
+
const FEED_END: Date = at("2026-09-09T00:00:00Z");
|
|
137
|
+
|
|
138
|
+
const TOKEN_HASH: string = "a".repeat(64);
|
|
139
|
+
|
|
140
|
+
interface ScheduleFixture {
|
|
141
|
+
id: ObjectID;
|
|
142
|
+
name: string;
|
|
143
|
+
timezone?: string | undefined;
|
|
144
|
+
projectId: ObjectID;
|
|
145
|
+
shiftConfigVersion: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface CapturedFindBy {
|
|
149
|
+
query: Record<string, unknown>;
|
|
150
|
+
select?: Record<string, unknown> | undefined;
|
|
151
|
+
props?: { isRoot?: boolean | undefined } | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setBillingEnabled(value: boolean): void {
|
|
155
|
+
(globalThis as EnvMockGlobal).__oneuptimeCalendarRendererBillingEnabled =
|
|
156
|
+
value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function scheduleFixture(
|
|
160
|
+
overrides?: Partial<ScheduleFixture> | undefined,
|
|
161
|
+
): ScheduleFixture {
|
|
162
|
+
return {
|
|
163
|
+
id: ObjectID.generate(),
|
|
164
|
+
name: "Payments",
|
|
165
|
+
timezone: "Europe/Stockholm",
|
|
166
|
+
projectId: ObjectID.generate(),
|
|
167
|
+
shiftConfigVersion: 3,
|
|
168
|
+
...(overrides || {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function alwaysOnLayer(users: Array<string>, startsAt: Date): LayerProps {
|
|
173
|
+
return {
|
|
174
|
+
users: users.map(user),
|
|
175
|
+
startDateTimeOfLayer: startsAt,
|
|
176
|
+
restrictionTimes: noRestriction(),
|
|
177
|
+
handOffTime: at("2026-08-31T09:00:00Z"),
|
|
178
|
+
rotation: rotation(EventInterval.Week, 1),
|
|
179
|
+
timezone: "UTC",
|
|
180
|
+
layerId: "layer-1",
|
|
181
|
+
layerName: "Primary",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function materialization(data: {
|
|
186
|
+
schedule: ScheduleFixture;
|
|
187
|
+
shifts: Array<MaterializedShift>;
|
|
188
|
+
truncated?: boolean | undefined;
|
|
189
|
+
layerProps?: Array<LayerProps> | undefined;
|
|
190
|
+
lastModifiedAt?: Date | undefined;
|
|
191
|
+
projectName?: string | undefined;
|
|
192
|
+
}): MaterializeResult {
|
|
193
|
+
return {
|
|
194
|
+
shifts: data.shifts,
|
|
195
|
+
truncated: data.truncated === true,
|
|
196
|
+
schedules: [
|
|
197
|
+
{
|
|
198
|
+
scheduleId: data.schedule.id.toString(),
|
|
199
|
+
scheduleName: data.schedule.name,
|
|
200
|
+
projectId: data.schedule.projectId.toString(),
|
|
201
|
+
...(data.projectName ? { projectName: data.projectName } : {}),
|
|
202
|
+
...(data.schedule.timezone
|
|
203
|
+
? { scheduleTimezone: data.schedule.timezone }
|
|
204
|
+
: {}),
|
|
205
|
+
shiftConfigVersion: data.schedule.shiftConfigVersion,
|
|
206
|
+
lastModifiedAt: data.lastModifiedAt || at("2026-08-01T10:00:00Z"),
|
|
207
|
+
truncated: data.truncated === true,
|
|
208
|
+
attachedPolicies: [],
|
|
209
|
+
layerProps: data.layerProps || [],
|
|
210
|
+
scheduleUserIds: [],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
users: [],
|
|
214
|
+
generatedAt: NOW,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/*
|
|
219
|
+
* The batched materialization of several schedules at once: exactly what
|
|
220
|
+
* OnCallShiftMaterializer.materializeForSchedules returns for a list of ids.
|
|
221
|
+
*/
|
|
222
|
+
function batchedMaterialization(
|
|
223
|
+
parts: Array<MaterializeResult>,
|
|
224
|
+
): MaterializeResult {
|
|
225
|
+
return {
|
|
226
|
+
shifts: parts.flatMap((part: MaterializeResult) => {
|
|
227
|
+
return part.shifts;
|
|
228
|
+
}),
|
|
229
|
+
truncated: parts.some((part: MaterializeResult) => {
|
|
230
|
+
return part.truncated;
|
|
231
|
+
}),
|
|
232
|
+
schedules: parts.flatMap((part: MaterializeResult) => {
|
|
233
|
+
return part.schedules;
|
|
234
|
+
}),
|
|
235
|
+
users: [],
|
|
236
|
+
generatedAt: NOW,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function shiftOn(
|
|
241
|
+
schedule: ScheduleFixture,
|
|
242
|
+
userId: string,
|
|
243
|
+
start: string,
|
|
244
|
+
end: string,
|
|
245
|
+
): MaterializedShift {
|
|
246
|
+
return shift({
|
|
247
|
+
scheduleId: schedule.id.toString(),
|
|
248
|
+
scheduleName: schedule.name,
|
|
249
|
+
projectId: schedule.projectId.toString(),
|
|
250
|
+
userId,
|
|
251
|
+
userName: `Name of ${userId}`,
|
|
252
|
+
start: at(start),
|
|
253
|
+
end: at(end),
|
|
254
|
+
shiftConfigVersion: schedule.shiftConfigVersion,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function personalRequest(data: {
|
|
259
|
+
projectId: ObjectID;
|
|
260
|
+
userId: ObjectID;
|
|
261
|
+
scheduleFilterId?: ObjectID | undefined;
|
|
262
|
+
includeCoveringShifts?: boolean | undefined;
|
|
263
|
+
tokenHash?: string | undefined;
|
|
264
|
+
}): FeedRenderRequest {
|
|
265
|
+
return {
|
|
266
|
+
kind: OnCallCalendarFeedKind.Personal,
|
|
267
|
+
feedId: ObjectID.generate(),
|
|
268
|
+
projectId: data.projectId,
|
|
269
|
+
userId: data.userId,
|
|
270
|
+
tokenHash: data.tokenHash || TOKEN_HASH,
|
|
271
|
+
includeCoveringShifts: data.includeCoveringShifts !== false,
|
|
272
|
+
scheduleFilterId: data.scheduleFilterId,
|
|
273
|
+
pastDays: 2,
|
|
274
|
+
futureDays: 7,
|
|
275
|
+
now: NOW,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scheduleRequest(data: {
|
|
280
|
+
projectId: ObjectID;
|
|
281
|
+
scheduleId: ObjectID;
|
|
282
|
+
includeCoverageGaps?: boolean | undefined;
|
|
283
|
+
minimumGapMinutes?: number | undefined;
|
|
284
|
+
}): FeedRenderRequest {
|
|
285
|
+
return {
|
|
286
|
+
kind: OnCallCalendarFeedKind.Schedule,
|
|
287
|
+
feedId: ObjectID.generate(),
|
|
288
|
+
projectId: data.projectId,
|
|
289
|
+
scheduleId: data.scheduleId,
|
|
290
|
+
tokenHash: TOKEN_HASH,
|
|
291
|
+
includeCoverageGaps: data.includeCoverageGaps === true,
|
|
292
|
+
minimumGapMinutes: data.minimumGapMinutes ?? 60,
|
|
293
|
+
pastDays: 2,
|
|
294
|
+
futureDays: 7,
|
|
295
|
+
now: NOW,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function projectRequest(data: {
|
|
300
|
+
projectId: ObjectID;
|
|
301
|
+
includeCoverageGaps?: boolean | undefined;
|
|
302
|
+
}): FeedRenderRequest {
|
|
303
|
+
return {
|
|
304
|
+
kind: OnCallCalendarFeedKind.Project,
|
|
305
|
+
feedId: ObjectID.generate(),
|
|
306
|
+
projectId: data.projectId,
|
|
307
|
+
tokenHash: TOKEN_HASH,
|
|
308
|
+
includeCoverageGaps: data.includeCoverageGaps === true,
|
|
309
|
+
minimumGapMinutes: 60,
|
|
310
|
+
pastDays: 2,
|
|
311
|
+
futureDays: 7,
|
|
312
|
+
now: NOW,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function unfold(body: string): string {
|
|
317
|
+
return body.replace(/\r\n /g, "");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function summaries(body: string): Array<string> {
|
|
321
|
+
return eventBlocks(body).map((block: Array<string>): string => {
|
|
322
|
+
const line: string | undefined = block.find((candidate: string) => {
|
|
323
|
+
return candidate.startsWith("SUMMARY:");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return line ? line.slice("SUMMARY:".length) : "";
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// -- Spies ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
let materializeForSchedules: jest.SpyInstance;
|
|
333
|
+
let candidateIds: jest.SpyInstance;
|
|
334
|
+
let scheduleFindBy: jest.SpyInstance;
|
|
335
|
+
let userFindOneById: jest.SpyInstance;
|
|
336
|
+
let projectFindOneById: jest.SpyInstance;
|
|
337
|
+
let getCurrentPlan: jest.SpyInstance;
|
|
338
|
+
let planCheck: jest.SpyInstance;
|
|
339
|
+
let dashboardUrl: jest.SpyInstance;
|
|
340
|
+
let recordDuration: jest.Mock;
|
|
341
|
+
let recordEvents: jest.Mock;
|
|
342
|
+
let setLastGood: jest.SpyInstance;
|
|
343
|
+
let setBody: jest.SpyInstance;
|
|
344
|
+
|
|
345
|
+
let projectId: ObjectID;
|
|
346
|
+
let me: ObjectID;
|
|
347
|
+
|
|
348
|
+
function installSchedules(rows: Array<ScheduleFixture>): void {
|
|
349
|
+
scheduleFindBy.mockImplementation(
|
|
350
|
+
async (args: CapturedFindBy): Promise<Array<ScheduleFixture>> => {
|
|
351
|
+
const wanted: unknown = args.query["projectId"];
|
|
352
|
+
|
|
353
|
+
if (wanted) {
|
|
354
|
+
return rows.filter((row: ScheduleFixture): boolean => {
|
|
355
|
+
return row.projectId.toString() === String(wanted);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return rows;
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
jest.clearAllMocks();
|
|
366
|
+
setBillingEnabled(false);
|
|
367
|
+
OnCallCalendarFeedCache.clearInProcessState();
|
|
368
|
+
OnCallCalendarFeedCache.configure({ renderConcurrency: 4 });
|
|
369
|
+
|
|
370
|
+
projectId = ObjectID.generate();
|
|
371
|
+
me = ObjectID.generate();
|
|
372
|
+
|
|
373
|
+
materializeForSchedules = jest.spyOn(
|
|
374
|
+
OnCallShiftMaterializer,
|
|
375
|
+
"materializeForSchedules",
|
|
376
|
+
);
|
|
377
|
+
candidateIds = jest
|
|
378
|
+
.spyOn(OnCallShiftMaterializer, "getCandidateScheduleIdsForUser")
|
|
379
|
+
.mockResolvedValue([]);
|
|
380
|
+
scheduleFindBy = jest
|
|
381
|
+
.spyOn(OnCallDutyPolicyScheduleService, "findBy")
|
|
382
|
+
.mockResolvedValue([]);
|
|
383
|
+
userFindOneById = jest
|
|
384
|
+
.spyOn(UserService, "findOneById")
|
|
385
|
+
.mockResolvedValue({ id: me, timezone: "America/New_York" } as never);
|
|
386
|
+
projectFindOneById = jest
|
|
387
|
+
.spyOn(ProjectService, "findOneById")
|
|
388
|
+
.mockResolvedValue({ id: projectId, name: "Acme" } as never);
|
|
389
|
+
getCurrentPlan = jest
|
|
390
|
+
.spyOn(ProjectService, "getCurrentPlan")
|
|
391
|
+
.mockResolvedValue({ plan: PlanType.Growth, isSubscriptionUnpaid: false });
|
|
392
|
+
planCheck = jest
|
|
393
|
+
.spyOn(SubscriptionPlan, "isFeatureAccessibleOnCurrentPlan")
|
|
394
|
+
.mockReturnValue(true);
|
|
395
|
+
dashboardUrl = jest
|
|
396
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
397
|
+
.mockResolvedValue({
|
|
398
|
+
toString: (): string => {
|
|
399
|
+
return DASHBOARD_URL;
|
|
400
|
+
},
|
|
401
|
+
} as never);
|
|
402
|
+
|
|
403
|
+
recordDuration = jest.fn();
|
|
404
|
+
recordEvents = jest.fn();
|
|
405
|
+
jest
|
|
406
|
+
.spyOn(AppMetrics, "getOnCallCalendarRenderDuration")
|
|
407
|
+
.mockReturnValue({ record: recordDuration } as never);
|
|
408
|
+
jest
|
|
409
|
+
.spyOn(AppMetrics, "getOnCallCalendarRenderEvents")
|
|
410
|
+
.mockReturnValue({ record: recordEvents } as never);
|
|
411
|
+
|
|
412
|
+
setLastGood = jest.spyOn(OnCallCalendarFeedCache, "setLastGood");
|
|
413
|
+
setBody = jest.spyOn(OnCallCalendarFeedCache, "setBody");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
afterEach(() => {
|
|
417
|
+
jest.restoreAllMocks();
|
|
418
|
+
OnCallCalendarFeedCache.clearInProcessState();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// -- Outcomes ---------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
describe("OnCallCalendarFeedRenderer.buildEmptyOutcome", () => {
|
|
424
|
+
test("is a header-only VCALENDAR whose X-WR-CALDESC carries the reason, with a content ETag", () => {
|
|
425
|
+
const outcome: FeedRenderOutcome =
|
|
426
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
427
|
+
kind: OnCallCalendarFeedKind.Personal,
|
|
428
|
+
reason: FEED_DISABLED_REASON,
|
|
429
|
+
now: NOW,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
433
|
+
expect(outcome.kind).toBe(OnCallCalendarFeedKind.Personal);
|
|
434
|
+
expect(outcome.body.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true);
|
|
435
|
+
expect(outcome.body).not.toContain("BEGIN:VEVENT");
|
|
436
|
+
expect(unfold(outcome.body)).toContain(FEED_DISABLED_REASON.slice(0, 40));
|
|
437
|
+
expect(outcome.etag).toBe(Response.getCalendarETag(outcome.body));
|
|
438
|
+
expect(outcome.lastModified).toBe(NOW);
|
|
439
|
+
expect(outcome.eventCount).toBe(0);
|
|
440
|
+
expect(outcome.stale).toBe(false);
|
|
441
|
+
expect(outcome.truncated).toBe(false);
|
|
442
|
+
expect(outcome.cacheHit).toBe(false);
|
|
443
|
+
expect(outcome.reason).toBe(FEED_DISABLED_REASON);
|
|
444
|
+
expect(outcome.retryAfterSeconds).toBeNull();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("names the schedule / project / filter in the calendar name", () => {
|
|
448
|
+
const scheduleOutcome: FeedRenderOutcome =
|
|
449
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
450
|
+
kind: OnCallCalendarFeedKind.Schedule,
|
|
451
|
+
reason: PLAN_REASON,
|
|
452
|
+
scheduleName: "Payments",
|
|
453
|
+
now: NOW,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(property(scheduleOutcome.body, "X-WR-CALNAME")).toBe("Payments");
|
|
457
|
+
|
|
458
|
+
const projectOutcome: FeedRenderOutcome =
|
|
459
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
460
|
+
kind: OnCallCalendarFeedKind.Project,
|
|
461
|
+
reason: PLAN_REASON,
|
|
462
|
+
projectName: "Acme",
|
|
463
|
+
now: NOW,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect(property(projectOutcome.body, "X-WR-CALNAME")).toContain("Acme");
|
|
467
|
+
|
|
468
|
+
const filteredOutcome: FeedRenderOutcome =
|
|
469
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
470
|
+
kind: OnCallCalendarFeedKind.Personal,
|
|
471
|
+
reason: FILTERED_SCHEDULE_REASON,
|
|
472
|
+
filterScheduleName: "Payments",
|
|
473
|
+
now: NOW,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(property(filteredOutcome.body, "X-WR-CALNAME")).toContain(
|
|
477
|
+
"Payments",
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("two empty outcomes for the same reason are byte-identical (same ETag)", () => {
|
|
482
|
+
const first: FeedRenderOutcome =
|
|
483
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
484
|
+
kind: OnCallCalendarFeedKind.Personal,
|
|
485
|
+
reason: PLAN_REASON,
|
|
486
|
+
now: NOW,
|
|
487
|
+
});
|
|
488
|
+
const second: FeedRenderOutcome =
|
|
489
|
+
OnCallCalendarFeedRenderer.buildEmptyOutcome({
|
|
490
|
+
kind: OnCallCalendarFeedKind.Personal,
|
|
491
|
+
reason: PLAN_REASON,
|
|
492
|
+
now: at("2027-01-01T00:00:00Z"),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
expect(first.body).toBe(second.body);
|
|
496
|
+
expect(first.etag).toBe(second.etag);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("OnCallCalendarFeedRenderer.buildUnavailableOutcome", () => {
|
|
501
|
+
test("carries the Retry-After and no body", () => {
|
|
502
|
+
const outcome: FeedRenderOutcome =
|
|
503
|
+
OnCallCalendarFeedRenderer.buildUnavailableOutcome(
|
|
504
|
+
OnCallCalendarFeedKind.Schedule,
|
|
505
|
+
60,
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
expect(outcome.status).toBe(FeedRenderStatus.Unavailable);
|
|
509
|
+
expect(outcome.body).toBe("");
|
|
510
|
+
expect(outcome.etag).toBe("");
|
|
511
|
+
expect(outcome.retryAfterSeconds).toBe(60);
|
|
512
|
+
expect(outcome.eventCount).toBe(0);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe("OnCallCalendarFeedRenderer.countEvents", () => {
|
|
517
|
+
test("counts VEVENT blocks", () => {
|
|
518
|
+
expect(OnCallCalendarFeedRenderer.countEvents("")).toBe(0);
|
|
519
|
+
expect(
|
|
520
|
+
OnCallCalendarFeedRenderer.countEvents(
|
|
521
|
+
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
|
|
522
|
+
),
|
|
523
|
+
).toBe(2);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// -- Plan ---------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
describe("OnCallCalendarFeedRenderer.isProjectOnPlan", () => {
|
|
530
|
+
test("with billing disabled every project is on plan and no plan is read", async () => {
|
|
531
|
+
setBillingEnabled(false);
|
|
532
|
+
|
|
533
|
+
expect(await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId)).toBe(
|
|
534
|
+
true,
|
|
535
|
+
);
|
|
536
|
+
expect(getCurrentPlan).not.toHaveBeenCalled();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("the explicit option overrides the environment", async () => {
|
|
540
|
+
setBillingEnabled(true);
|
|
541
|
+
|
|
542
|
+
expect(
|
|
543
|
+
await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId, {
|
|
544
|
+
billingEnabled: false,
|
|
545
|
+
}),
|
|
546
|
+
).toBe(true);
|
|
547
|
+
expect(getCurrentPlan).not.toHaveBeenCalled();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("with billing enabled the check delegates to SubscriptionPlan for the Growth feature", async () => {
|
|
551
|
+
setBillingEnabled(true);
|
|
552
|
+
getCurrentPlan.mockResolvedValue({
|
|
553
|
+
plan: PlanType.Scale,
|
|
554
|
+
isSubscriptionUnpaid: false,
|
|
555
|
+
});
|
|
556
|
+
planCheck.mockReturnValue(true);
|
|
557
|
+
|
|
558
|
+
expect(await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId)).toBe(
|
|
559
|
+
true,
|
|
560
|
+
);
|
|
561
|
+
expect(getCurrentPlan).toHaveBeenCalledWith(projectId);
|
|
562
|
+
expect(planCheck).toHaveBeenCalledTimes(1);
|
|
563
|
+
expect(planCheck.mock.calls[0]?.[0]).toBe(PlanType.Growth);
|
|
564
|
+
expect(planCheck.mock.calls[0]?.[1]).toBe(PlanType.Scale);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("a plan below Growth is off plan", async () => {
|
|
568
|
+
setBillingEnabled(true);
|
|
569
|
+
getCurrentPlan.mockResolvedValue({
|
|
570
|
+
plan: PlanType.Free,
|
|
571
|
+
isSubscriptionUnpaid: false,
|
|
572
|
+
});
|
|
573
|
+
planCheck.mockReturnValue(false);
|
|
574
|
+
|
|
575
|
+
expect(await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId)).toBe(
|
|
576
|
+
false,
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("no plan at all is off plan (fails closed)", async () => {
|
|
581
|
+
setBillingEnabled(true);
|
|
582
|
+
getCurrentPlan.mockResolvedValue({
|
|
583
|
+
plan: null,
|
|
584
|
+
isSubscriptionUnpaid: false,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
expect(await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId)).toBe(
|
|
588
|
+
false,
|
|
589
|
+
);
|
|
590
|
+
expect(planCheck).not.toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("a plan read that throws is off plan, logged at warn, never thrown", async () => {
|
|
594
|
+
setBillingEnabled(true);
|
|
595
|
+
getCurrentPlan.mockRejectedValue(new Error("project gone"));
|
|
596
|
+
|
|
597
|
+
expect(await OnCallCalendarFeedRenderer.isProjectOnPlan(projectId)).toBe(
|
|
598
|
+
false,
|
|
599
|
+
);
|
|
600
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// -- Cache key ------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
describe("OnCallCalendarFeedRenderer.buildBodyCacheKey", () => {
|
|
607
|
+
const first: ScheduleFixture = scheduleFixture({ shiftConfigVersion: 1 });
|
|
608
|
+
const second: ScheduleFixture = scheduleFixture({ shiftConfigVersion: 2 });
|
|
609
|
+
|
|
610
|
+
function key(
|
|
611
|
+
request: FeedRenderRequest,
|
|
612
|
+
schedules: Array<ScheduleFixture>,
|
|
613
|
+
now?: Date | undefined,
|
|
614
|
+
): string {
|
|
615
|
+
return OnCallCalendarFeedRenderer.buildBodyCacheKey({
|
|
616
|
+
request,
|
|
617
|
+
pastDays: request.pastDays,
|
|
618
|
+
futureDays: request.futureDays,
|
|
619
|
+
schedules,
|
|
620
|
+
now: now || NOW,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
test("starts with the kind and the token HASH, and encodes the window and the day bucket", () => {
|
|
625
|
+
const request: FeedRenderRequest = personalRequest({
|
|
626
|
+
projectId,
|
|
627
|
+
userId: me,
|
|
628
|
+
});
|
|
629
|
+
const value: string = key(request, [first]);
|
|
630
|
+
|
|
631
|
+
expect(value.startsWith(`user:${TOKEN_HASH}:`)).toBe(false);
|
|
632
|
+
expect(
|
|
633
|
+
value.startsWith(`${OnCallCalendarFeedKind.Personal}:${TOKEN_HASH}:`),
|
|
634
|
+
).toBe(true);
|
|
635
|
+
expect(value).toContain(":w=2/7:");
|
|
636
|
+
expect(value.endsWith(`:${CalendarFeedWindow.getUtcDayBucket(NOW)}`)).toBe(
|
|
637
|
+
true,
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("is deterministic and independent of schedule order", () => {
|
|
642
|
+
const request: FeedRenderRequest = personalRequest({
|
|
643
|
+
projectId,
|
|
644
|
+
userId: me,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expect(key(request, [first, second])).toBe(key(request, [second, first]));
|
|
648
|
+
expect(key(request, [first])).toBe(key(request, [first]));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("changes with any schedule's shiftConfigVersion, the candidate set, the filters, the window, the token and the day", () => {
|
|
652
|
+
const request: FeedRenderRequest = personalRequest({
|
|
653
|
+
projectId,
|
|
654
|
+
userId: me,
|
|
655
|
+
});
|
|
656
|
+
const base: string = key(request, [first]);
|
|
657
|
+
|
|
658
|
+
expect(key(request, [{ ...first, shiftConfigVersion: 9 }])).not.toBe(base);
|
|
659
|
+
expect(key(request, [first, second])).not.toBe(base);
|
|
660
|
+
expect(
|
|
661
|
+
key(
|
|
662
|
+
personalRequest({
|
|
663
|
+
projectId,
|
|
664
|
+
userId: me,
|
|
665
|
+
includeCoveringShifts: false,
|
|
666
|
+
}),
|
|
667
|
+
[first],
|
|
668
|
+
),
|
|
669
|
+
).not.toBe(base);
|
|
670
|
+
expect(
|
|
671
|
+
key(
|
|
672
|
+
personalRequest({
|
|
673
|
+
projectId,
|
|
674
|
+
userId: me,
|
|
675
|
+
scheduleFilterId: ObjectID.generate(),
|
|
676
|
+
}),
|
|
677
|
+
[first],
|
|
678
|
+
),
|
|
679
|
+
).not.toBe(base);
|
|
680
|
+
expect(key({ ...request, pastDays: 3 }, [first])).not.toBe(base);
|
|
681
|
+
expect(
|
|
682
|
+
key(
|
|
683
|
+
personalRequest({ projectId, userId: me, tokenHash: "b".repeat(64) }),
|
|
684
|
+
[first],
|
|
685
|
+
),
|
|
686
|
+
).not.toBe(base);
|
|
687
|
+
expect(key(request, [first], at("2026-09-02T00:00:01Z"))).not.toBe(base);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("shared feeds encode the gap settings; the same schedule under different kinds never shares a key", () => {
|
|
691
|
+
const scheduleKey: string = key(
|
|
692
|
+
scheduleRequest({
|
|
693
|
+
projectId,
|
|
694
|
+
scheduleId: first.id,
|
|
695
|
+
includeCoverageGaps: true,
|
|
696
|
+
minimumGapMinutes: 30,
|
|
697
|
+
}),
|
|
698
|
+
[first],
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
expect(scheduleKey).toContain(":g=1;m=30:");
|
|
702
|
+
expect(scheduleKey.startsWith(`${OnCallCalendarFeedKind.Schedule}:`)).toBe(
|
|
703
|
+
true,
|
|
704
|
+
);
|
|
705
|
+
expect(
|
|
706
|
+
key(
|
|
707
|
+
scheduleRequest({
|
|
708
|
+
projectId,
|
|
709
|
+
scheduleId: first.id,
|
|
710
|
+
includeCoverageGaps: false,
|
|
711
|
+
minimumGapMinutes: 30,
|
|
712
|
+
}),
|
|
713
|
+
[first],
|
|
714
|
+
),
|
|
715
|
+
).not.toBe(scheduleKey);
|
|
716
|
+
expect(key(projectRequest({ projectId }), [first])).not.toBe(scheduleKey);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// -- loadSchedules ----------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
describe("OnCallCalendarFeedRenderer.loadSchedules", () => {
|
|
723
|
+
test("an empty id list reads nothing", async () => {
|
|
724
|
+
expect(await OnCallCalendarFeedRenderer.loadSchedules([])).toEqual([]);
|
|
725
|
+
expect(scheduleFindBy).not.toHaveBeenCalled();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("preserves input order, drops unknown ids and duplicates, omits a missing timezone, reads as root", async () => {
|
|
729
|
+
const legacy: ScheduleFixture = scheduleFixture({
|
|
730
|
+
name: "Legacy",
|
|
731
|
+
timezone: undefined,
|
|
732
|
+
shiftConfigVersion: "7" as unknown as number,
|
|
733
|
+
});
|
|
734
|
+
const payments: ScheduleFixture = scheduleFixture({ name: "Payments" });
|
|
735
|
+
|
|
736
|
+
installSchedules([payments, legacy]);
|
|
737
|
+
|
|
738
|
+
const unknown: ObjectID = ObjectID.generate();
|
|
739
|
+
|
|
740
|
+
const loaded: Array<{
|
|
741
|
+
id: ObjectID;
|
|
742
|
+
name: string;
|
|
743
|
+
timezone?: string | undefined;
|
|
744
|
+
shiftConfigVersion: number;
|
|
745
|
+
}> = await OnCallCalendarFeedRenderer.loadSchedules([
|
|
746
|
+
legacy.id,
|
|
747
|
+
unknown,
|
|
748
|
+
payments.id,
|
|
749
|
+
legacy.id,
|
|
750
|
+
]);
|
|
751
|
+
|
|
752
|
+
expect(
|
|
753
|
+
loaded.map((info: { name: string }): string => {
|
|
754
|
+
return info.name;
|
|
755
|
+
}),
|
|
756
|
+
).toEqual(["Legacy", "Payments"]);
|
|
757
|
+
expect("timezone" in (loaded[0] || {})).toBe(false);
|
|
758
|
+
expect(loaded[0]?.shiftConfigVersion).toBe(7);
|
|
759
|
+
expect(loaded[1]?.timezone).toBe("Europe/Stockholm");
|
|
760
|
+
|
|
761
|
+
const call: CapturedFindBy = scheduleFindBy.mock
|
|
762
|
+
.calls[0]?.[0] as CapturedFindBy;
|
|
763
|
+
|
|
764
|
+
expect(call.props?.isRoot).toBe(true);
|
|
765
|
+
expect(call.select?.["shiftConfigVersion"]).toBe(true);
|
|
766
|
+
expect(call.select?.["timezone"]).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// -- Personal feed ----------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
describe("OnCallCalendarFeedRenderer.render (personal feed)", () => {
|
|
773
|
+
let schedule: ScheduleFixture;
|
|
774
|
+
|
|
775
|
+
beforeEach(() => {
|
|
776
|
+
schedule = scheduleFixture({ projectId });
|
|
777
|
+
installSchedules([schedule]);
|
|
778
|
+
candidateIds.mockResolvedValue([schedule.id]);
|
|
779
|
+
materializeForSchedules.mockResolvedValue(
|
|
780
|
+
materialization({
|
|
781
|
+
schedule,
|
|
782
|
+
shifts: [
|
|
783
|
+
shiftOn(
|
|
784
|
+
schedule,
|
|
785
|
+
me.toString(),
|
|
786
|
+
"2026-09-02T07:00:00Z",
|
|
787
|
+
"2026-09-02T15:00:00Z",
|
|
788
|
+
),
|
|
789
|
+
shiftOn(
|
|
790
|
+
schedule,
|
|
791
|
+
"someone-else",
|
|
792
|
+
"2026-09-02T15:00:00Z",
|
|
793
|
+
"2026-09-02T23:00:00Z",
|
|
794
|
+
),
|
|
795
|
+
shiftOn(
|
|
796
|
+
schedule,
|
|
797
|
+
me.toString(),
|
|
798
|
+
"2026-09-03T07:00:00Z",
|
|
799
|
+
"2026-09-03T15:00:00Z",
|
|
800
|
+
),
|
|
801
|
+
],
|
|
802
|
+
}),
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("a user that no longer exists gets an empty calendar, and nothing is materialized", async () => {
|
|
807
|
+
userFindOneById.mockResolvedValue(null);
|
|
808
|
+
|
|
809
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
810
|
+
personalRequest({ projectId, userId: me }),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
814
|
+
expect(outcome.reason).toBe(USER_MISSING_REASON);
|
|
815
|
+
expect(candidateIds).not.toHaveBeenCalled();
|
|
816
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("a project below plan gets an empty calendar that says so, in the viewer's zone", async () => {
|
|
820
|
+
setBillingEnabled(true);
|
|
821
|
+
planCheck.mockReturnValue(false);
|
|
822
|
+
|
|
823
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
824
|
+
personalRequest({ projectId, userId: me }),
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
828
|
+
expect(outcome.reason).toBe(PLAN_REASON);
|
|
829
|
+
expect(unfold(outcome.body)).toContain(PLAN_REASON.slice(0, 40));
|
|
830
|
+
expect(property(outcome.body, "X-WR-TIMEZONE")).toBe("America/New_York");
|
|
831
|
+
expect(candidateIds).not.toHaveBeenCalled();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("no eligible schedule gets an empty calendar", async () => {
|
|
835
|
+
candidateIds.mockResolvedValue([]);
|
|
836
|
+
|
|
837
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
838
|
+
personalRequest({ projectId, userId: me }),
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
842
|
+
expect(outcome.reason).toBe(NO_SCHEDULES_REASON);
|
|
843
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("a ?schedule= filter the user is not on gets the filtered reason and names the schedule", async () => {
|
|
847
|
+
candidateIds.mockResolvedValue([]);
|
|
848
|
+
|
|
849
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
850
|
+
personalRequest({ projectId, userId: me, scheduleFilterId: schedule.id }),
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
854
|
+
expect(outcome.reason).toBe(FILTERED_SCHEDULE_REASON);
|
|
855
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toContain("Payments");
|
|
856
|
+
|
|
857
|
+
const candidates: {
|
|
858
|
+
scheduleId?: ObjectID;
|
|
859
|
+
projectIds?: Array<ObjectID>;
|
|
860
|
+
includeCoveringShifts: boolean;
|
|
861
|
+
windowStart: Date;
|
|
862
|
+
windowEnd: Date;
|
|
863
|
+
} = candidateIds.mock.calls[0]?.[0] as {
|
|
864
|
+
scheduleId?: ObjectID;
|
|
865
|
+
projectIds?: Array<ObjectID>;
|
|
866
|
+
includeCoveringShifts: boolean;
|
|
867
|
+
windowStart: Date;
|
|
868
|
+
windowEnd: Date;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
expect(candidates.scheduleId?.toString()).toBe(schedule.id.toString());
|
|
872
|
+
expect(candidates.projectIds?.map(String)).toEqual([projectId.toString()]);
|
|
873
|
+
expect(candidates.includeCoveringShifts).toBe(true);
|
|
874
|
+
expect(candidates.windowStart.toISOString()).toBe(FEED_START.toISOString());
|
|
875
|
+
expect(candidates.windowEnd.toISOString()).toBe(FEED_END.toISOString());
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
/*
|
|
879
|
+
* Regression: the filter schedule was loaded by id as ROOT, with no
|
|
880
|
+
* projectId predicate, and its name was copied into the empty calendar's
|
|
881
|
+
* X-WR-CALNAME / X-WR-CALDESC before anything checked which project it
|
|
882
|
+
* belongs to. `?schedule=<any uuid>` on a personal feed link therefore
|
|
883
|
+
* turned that link into a schedule-id-to-name oracle across every tenant on
|
|
884
|
+
* the instance, over a public, unauthenticated route.
|
|
885
|
+
*/
|
|
886
|
+
test("a ?schedule= filter pointing at ANOTHER project's schedule never names it", async () => {
|
|
887
|
+
const foreign: ScheduleFixture = scheduleFixture({
|
|
888
|
+
projectId: ObjectID.generate(),
|
|
889
|
+
name: "ProjectB Secret Rota",
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
installSchedules([foreign]);
|
|
893
|
+
candidateIds.mockResolvedValue([]);
|
|
894
|
+
|
|
895
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
896
|
+
personalRequest({ projectId, userId: me, scheduleFilterId: foreign.id }),
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
900
|
+
expect(outcome.reason).toBe(FILTERED_SCHEDULE_REASON);
|
|
901
|
+
expect(unfold(outcome.body)).not.toContain("ProjectB Secret Rota");
|
|
902
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toBe("OneUptime On-Call");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("a candidate schedule from ANOTHER project never renders under this project's token", async () => {
|
|
906
|
+
const foreign: ScheduleFixture = scheduleFixture({
|
|
907
|
+
projectId: ObjectID.generate(),
|
|
908
|
+
});
|
|
909
|
+
installSchedules([foreign]);
|
|
910
|
+
candidateIds.mockResolvedValue([foreign.id]);
|
|
911
|
+
|
|
912
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
913
|
+
personalRequest({ projectId, userId: me }),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
917
|
+
expect(outcome.reason).toBe(NO_SCHEDULES_REASON);
|
|
918
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("renders ONLY the viewer's shifts, in the viewer's zone, over the day-aligned window, caching body and last-good", async () => {
|
|
922
|
+
const request: FeedRenderRequest = personalRequest({
|
|
923
|
+
projectId,
|
|
924
|
+
userId: me,
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
const outcome: FeedRenderOutcome =
|
|
928
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
929
|
+
|
|
930
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
931
|
+
expect(outcome.cacheHit).toBe(false);
|
|
932
|
+
expect(outcome.stale).toBe(false);
|
|
933
|
+
expect(outcome.truncated).toBe(false);
|
|
934
|
+
expect(outcome.eventCount).toBe(2);
|
|
935
|
+
expect(OnCallCalendarFeedRenderer.countEvents(outcome.body)).toBe(2);
|
|
936
|
+
/* The fixture attaches exactly one policy, hence the suffix. */
|
|
937
|
+
expect(summaries(outcome.body)).toEqual([
|
|
938
|
+
"On-call · Payments · Payments Policy",
|
|
939
|
+
"On-call · Payments · Payments Policy",
|
|
940
|
+
]);
|
|
941
|
+
expect(outcome.body).not.toContain("someone-else");
|
|
942
|
+
expect(property(outcome.body, "X-WR-TIMEZONE")).toBe("America/New_York");
|
|
943
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toBe("OneUptime On-Call");
|
|
944
|
+
expect(outcome.etag).toBe(Response.getCalendarETag(outcome.body));
|
|
945
|
+
expect(outcome.lastModified.toISOString()).toBe("2026-08-01T10:00:00.000Z");
|
|
946
|
+
|
|
947
|
+
const materializeArgs: {
|
|
948
|
+
scheduleIds: Array<ObjectID>;
|
|
949
|
+
windowStart: Date;
|
|
950
|
+
windowEnd: Date;
|
|
951
|
+
now: Date;
|
|
952
|
+
maxSimulationIterations: number;
|
|
953
|
+
} = materializeForSchedules.mock.calls[0]?.[0] as {
|
|
954
|
+
scheduleIds: Array<ObjectID>;
|
|
955
|
+
windowStart: Date;
|
|
956
|
+
windowEnd: Date;
|
|
957
|
+
now: Date;
|
|
958
|
+
maxSimulationIterations: number;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
expect(
|
|
962
|
+
materializeArgs.scheduleIds.map((id: ObjectID) => {
|
|
963
|
+
return id.toString();
|
|
964
|
+
}),
|
|
965
|
+
).toEqual([schedule.id.toString()]);
|
|
966
|
+
expect(materializeArgs.windowStart.toISOString()).toBe(
|
|
967
|
+
FEED_START.toISOString(),
|
|
968
|
+
);
|
|
969
|
+
expect(materializeArgs.windowEnd.toISOString()).toBe(
|
|
970
|
+
FEED_END.toISOString(),
|
|
971
|
+
);
|
|
972
|
+
expect(materializeArgs.maxSimulationIterations).toBe(200000);
|
|
973
|
+
|
|
974
|
+
expect(setBody).toHaveBeenCalledTimes(1);
|
|
975
|
+
expect(setLastGood).toHaveBeenCalledTimes(1);
|
|
976
|
+
expect(recordDuration).toHaveBeenCalledTimes(1);
|
|
977
|
+
expect(recordEvents).toHaveBeenCalledWith(2, {
|
|
978
|
+
[AppMetrics.ON_CALL_CALENDAR_FEED_KIND_ATTRIBUTE]: "user",
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test("the second fetch of the same key is a body-cache hit: no materialization, no metrics, same bytes", async () => {
|
|
983
|
+
const request: FeedRenderRequest = personalRequest({
|
|
984
|
+
projectId,
|
|
985
|
+
userId: me,
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const first: FeedRenderOutcome =
|
|
989
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
990
|
+
const second: FeedRenderOutcome =
|
|
991
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
992
|
+
|
|
993
|
+
expect(second.status).toBe(FeedRenderStatus.Rendered);
|
|
994
|
+
expect(second.cacheHit).toBe(true);
|
|
995
|
+
expect(second.body).toBe(first.body);
|
|
996
|
+
expect(second.etag).toBe(first.etag);
|
|
997
|
+
expect(second.lastModified.getTime()).toBe(first.lastModified.getTime());
|
|
998
|
+
expect(second.eventCount).toBe(2);
|
|
999
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
1000
|
+
expect(recordDuration).toHaveBeenCalledTimes(1);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test("a configuration edit (shiftConfigVersion bump) is a new body key AND a new schedule-level render", async () => {
|
|
1004
|
+
const request: FeedRenderRequest = personalRequest({
|
|
1005
|
+
projectId,
|
|
1006
|
+
userId: me,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1010
|
+
|
|
1011
|
+
schedule.shiftConfigVersion = 4;
|
|
1012
|
+
installSchedules([schedule]);
|
|
1013
|
+
|
|
1014
|
+
const after: FeedRenderOutcome =
|
|
1015
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1016
|
+
|
|
1017
|
+
expect(after.cacheHit).toBe(false);
|
|
1018
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(2);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("two feeds on the same schedule share ONE schedule-level expansion", async () => {
|
|
1022
|
+
const other: ObjectID = ObjectID.generate();
|
|
1023
|
+
|
|
1024
|
+
await OnCallCalendarFeedRenderer.render(
|
|
1025
|
+
personalRequest({ projectId, userId: me, tokenHash: "c".repeat(64) }),
|
|
1026
|
+
);
|
|
1027
|
+
await OnCallCalendarFeedRenderer.render(
|
|
1028
|
+
personalRequest({ projectId, userId: other, tokenHash: "d".repeat(64) }),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
1032
|
+
expect(setBody).toHaveBeenCalledTimes(2);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test("a viewer without a timezone renders in UTC", async () => {
|
|
1036
|
+
userFindOneById.mockResolvedValue({ id: me } as never);
|
|
1037
|
+
|
|
1038
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1039
|
+
personalRequest({ projectId, userId: me }),
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
expect(property(outcome.body, "X-WR-TIMEZONE")).toBe("UTC");
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("the dashboard URL feeds the event URL and the cover link", async () => {
|
|
1046
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1047
|
+
personalRequest({ projectId, userId: me }),
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
expect(dashboardUrl).toHaveBeenCalled();
|
|
1051
|
+
expect(unfold(outcome.body)).toContain(DASHBOARD_URL);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
/*
|
|
1055
|
+
* A policy-scoped override materializes as a variant shift owned by the
|
|
1056
|
+
* SUBSTITUTE, so the per-user filter drops it out of the rostered user's
|
|
1057
|
+
* feed. It still has to reach the mapper as context, or the rostered user's
|
|
1058
|
+
* own event would keep claiming to page them through a policy that now
|
|
1059
|
+
* pages somebody else.
|
|
1060
|
+
*/
|
|
1061
|
+
test("a policy variant that pages somebody else shows up on the rostered user's own event", async () => {
|
|
1062
|
+
const mine: MaterializedShift = shiftOn(
|
|
1063
|
+
schedule,
|
|
1064
|
+
me.toString(),
|
|
1065
|
+
"2026-09-02T07:00:00Z",
|
|
1066
|
+
"2026-09-02T15:00:00Z",
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
const substitute: MaterializedShift = {
|
|
1070
|
+
...shiftOn(
|
|
1071
|
+
schedule,
|
|
1072
|
+
"user-b",
|
|
1073
|
+
"2026-09-02T07:00:00Z",
|
|
1074
|
+
"2026-09-02T15:00:00Z",
|
|
1075
|
+
),
|
|
1076
|
+
userName: "Bob Berg",
|
|
1077
|
+
policyVariantOf: {
|
|
1078
|
+
policyId: "pol-1",
|
|
1079
|
+
policyName: "Payments Policy",
|
|
1080
|
+
globalUserId: me.toString(),
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
materializeForSchedules.mockResolvedValue(
|
|
1085
|
+
materialization({ schedule, shifts: [mine, substitute] }),
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1089
|
+
personalRequest({ projectId, userId: me }),
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
// The substitute's variant never becomes an event in MY calendar...
|
|
1093
|
+
expect(eventBlocks(outcome.body)).toHaveLength(1);
|
|
1094
|
+
expect(summaries(outcome.body)[0]).not.toContain("Bob Berg");
|
|
1095
|
+
// ...but my own event says who is really paged for that policy.
|
|
1096
|
+
expect(unfold(outcome.body)).toContain(
|
|
1097
|
+
"For Payments Policy\\, Bob Berg is paged instead from",
|
|
1098
|
+
);
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// -- Stale-while-error ------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
describe("OnCallCalendarFeedRenderer.render (stale-while-error)", () => {
|
|
1105
|
+
let schedule: ScheduleFixture;
|
|
1106
|
+
let request: FeedRenderRequest;
|
|
1107
|
+
|
|
1108
|
+
beforeEach(() => {
|
|
1109
|
+
schedule = scheduleFixture({ projectId });
|
|
1110
|
+
installSchedules([schedule]);
|
|
1111
|
+
candidateIds.mockResolvedValue([schedule.id]);
|
|
1112
|
+
request = personalRequest({ projectId, userId: me });
|
|
1113
|
+
materializeForSchedules.mockResolvedValue(
|
|
1114
|
+
materialization({
|
|
1115
|
+
schedule,
|
|
1116
|
+
shifts: [
|
|
1117
|
+
shiftOn(
|
|
1118
|
+
schedule,
|
|
1119
|
+
me.toString(),
|
|
1120
|
+
"2026-09-02T07:00:00Z",
|
|
1121
|
+
"2026-09-02T15:00:00Z",
|
|
1122
|
+
),
|
|
1123
|
+
],
|
|
1124
|
+
}),
|
|
1125
|
+
);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
async function renderOnceAndExpireBodyCache(): Promise<FeedRenderOutcome> {
|
|
1129
|
+
const good: FeedRenderOutcome =
|
|
1130
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1131
|
+
|
|
1132
|
+
/*
|
|
1133
|
+
* Drop the fresh body (and the schedule-level entry) but keep the
|
|
1134
|
+
* last-good tier: purges are exactly what the last-good tier survives.
|
|
1135
|
+
*/
|
|
1136
|
+
await OnCallCalendarFeedCache.purgeForUser(
|
|
1137
|
+
projectId.toString(),
|
|
1138
|
+
me.toString(),
|
|
1139
|
+
);
|
|
1140
|
+
await OnCallCalendarFeedCache.purgeForSchedule(schedule.id.toString());
|
|
1141
|
+
|
|
1142
|
+
return good;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
test("render cap reached with nothing cached: Unavailable + Retry-After 60, no slot leaked", async () => {
|
|
1146
|
+
jest
|
|
1147
|
+
.spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
|
|
1148
|
+
.mockReturnValue(false);
|
|
1149
|
+
|
|
1150
|
+
const outcome: FeedRenderOutcome =
|
|
1151
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1152
|
+
|
|
1153
|
+
expect(outcome.status).toBe(FeedRenderStatus.Unavailable);
|
|
1154
|
+
expect(outcome.retryAfterSeconds).toBe(RENDER_CAP_RETRY_AFTER_SECONDS);
|
|
1155
|
+
expect(RENDER_CAP_RETRY_AFTER_SECONDS).toBe(60);
|
|
1156
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
1157
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test("render cap reached with a last-good body: that body, flagged stale", async () => {
|
|
1161
|
+
const good: FeedRenderOutcome = await renderOnceAndExpireBodyCache();
|
|
1162
|
+
|
|
1163
|
+
jest
|
|
1164
|
+
.spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
|
|
1165
|
+
.mockReturnValue(false);
|
|
1166
|
+
|
|
1167
|
+
const outcome: FeedRenderOutcome =
|
|
1168
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1169
|
+
|
|
1170
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1171
|
+
expect(outcome.stale).toBe(true);
|
|
1172
|
+
expect(outcome.truncated).toBe(true);
|
|
1173
|
+
expect(outcome.cacheHit).toBe(false);
|
|
1174
|
+
expect(outcome.body).toBe(good.body);
|
|
1175
|
+
expect(outcome.etag).toBe(good.etag);
|
|
1176
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
/*
|
|
1180
|
+
* Regression: the last-good tier was keyed by the BODY key, which carries
|
|
1181
|
+
* every schedule's shiftConfigVersion and the UTC day bucket. Every
|
|
1182
|
+
* configuration edit bumps a version and every feed's day bucket rolls at
|
|
1183
|
+
* midnight UTC, so the stale body became unreachable at exactly the two
|
|
1184
|
+
* moments it is most needed -- the moments when every subscriber's body
|
|
1185
|
+
* cache misses at once and the four render slots are scarcest -- and the
|
|
1186
|
+
* render cap answered 503 instead. The key now carries the feed's identity
|
|
1187
|
+
* and its SORTED SCHEDULE-ID SET (so a body rendered for a roster the
|
|
1188
|
+
* subscriber has since left can still never be served) and nothing else.
|
|
1189
|
+
*/
|
|
1190
|
+
test("a configuration edit does not throw away the last-good body", async () => {
|
|
1191
|
+
const good: FeedRenderOutcome = await renderOnceAndExpireBodyCache();
|
|
1192
|
+
|
|
1193
|
+
/* Every roster-affecting hook bumps this. */
|
|
1194
|
+
schedule.shiftConfigVersion = schedule.shiftConfigVersion + 1;
|
|
1195
|
+
installSchedules([schedule]);
|
|
1196
|
+
|
|
1197
|
+
jest
|
|
1198
|
+
.spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
|
|
1199
|
+
.mockReturnValue(false);
|
|
1200
|
+
|
|
1201
|
+
const outcome: FeedRenderOutcome =
|
|
1202
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1203
|
+
|
|
1204
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1205
|
+
expect(outcome.stale).toBe(true);
|
|
1206
|
+
expect(outcome.body).toBe(good.body);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test("the UTC day rolling does not throw away the last-good body", async () => {
|
|
1210
|
+
const good: FeedRenderOutcome = await renderOnceAndExpireBodyCache();
|
|
1211
|
+
|
|
1212
|
+
jest
|
|
1213
|
+
.spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
|
|
1214
|
+
.mockReturnValue(false);
|
|
1215
|
+
|
|
1216
|
+
const tomorrow: FeedRenderRequest = {
|
|
1217
|
+
...(request as FeedRenderRequest),
|
|
1218
|
+
now: at("2026-09-02T00:03:00Z"),
|
|
1219
|
+
} as FeedRenderRequest;
|
|
1220
|
+
|
|
1221
|
+
const outcome: FeedRenderOutcome =
|
|
1222
|
+
await OnCallCalendarFeedRenderer.render(tomorrow);
|
|
1223
|
+
|
|
1224
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1225
|
+
expect(outcome.stale).toBe(true);
|
|
1226
|
+
expect(outcome.body).toBe(good.body);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
test("a body rendered for a roster the user has since left is never served stale", async () => {
|
|
1230
|
+
const other: ScheduleFixture = scheduleFixture({
|
|
1231
|
+
projectId,
|
|
1232
|
+
name: "Search",
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
await renderOnceAndExpireBodyCache();
|
|
1236
|
+
|
|
1237
|
+
/* The user is now on a DIFFERENT schedule: a new candidate set. */
|
|
1238
|
+
installSchedules([other]);
|
|
1239
|
+
candidateIds.mockResolvedValue([other.id]);
|
|
1240
|
+
|
|
1241
|
+
jest
|
|
1242
|
+
.spyOn(OnCallCalendarFeedCache, "tryAcquireRenderSlot")
|
|
1243
|
+
.mockReturnValue(false);
|
|
1244
|
+
|
|
1245
|
+
const outcome: FeedRenderOutcome =
|
|
1246
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1247
|
+
|
|
1248
|
+
expect(outcome.status).toBe(FeedRenderStatus.Unavailable);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test("a render that throws with nothing cached: Unavailable, logged, slot released", async () => {
|
|
1252
|
+
materializeForSchedules.mockRejectedValue(new Error("layer exploded"));
|
|
1253
|
+
|
|
1254
|
+
const outcome: FeedRenderOutcome =
|
|
1255
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1256
|
+
|
|
1257
|
+
expect(outcome.status).toBe(FeedRenderStatus.Unavailable);
|
|
1258
|
+
expect(outcome.retryAfterSeconds).toBe(60);
|
|
1259
|
+
expect(logger.error).toHaveBeenCalled();
|
|
1260
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
|
|
1261
|
+
expect(setLastGood).not.toHaveBeenCalled();
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("a render that throws with a last-good body serves it stale and keeps the last-good intact", async () => {
|
|
1265
|
+
const good: FeedRenderOutcome = await renderOnceAndExpireBodyCache();
|
|
1266
|
+
|
|
1267
|
+
materializeForSchedules.mockRejectedValue(new Error("layer exploded"));
|
|
1268
|
+
|
|
1269
|
+
const outcome: FeedRenderOutcome =
|
|
1270
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1271
|
+
|
|
1272
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1273
|
+
expect(outcome.stale).toBe(true);
|
|
1274
|
+
expect(outcome.body).toBe(good.body);
|
|
1275
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
|
|
1276
|
+
|
|
1277
|
+
/* And it is still there for the next failure. */
|
|
1278
|
+
const again: FeedRenderOutcome =
|
|
1279
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1280
|
+
|
|
1281
|
+
expect(again.stale).toBe(true);
|
|
1282
|
+
expect(again.body).toBe(good.body);
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
test("an iteration-capped expansion with a last-good body serves the complete older body, not the partial one", async () => {
|
|
1286
|
+
const good: FeedRenderOutcome = await renderOnceAndExpireBodyCache();
|
|
1287
|
+
|
|
1288
|
+
materializeForSchedules.mockResolvedValue(
|
|
1289
|
+
materialization({ schedule, shifts: [], truncated: true }),
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
const outcome: FeedRenderOutcome =
|
|
1293
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1294
|
+
|
|
1295
|
+
expect(outcome.stale).toBe(true);
|
|
1296
|
+
expect(outcome.truncated).toBe(true);
|
|
1297
|
+
expect(outcome.body).toBe(good.body);
|
|
1298
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("an iteration-capped expansion with nothing cached goes out partial, with a note, and is NOT stored as last-good", async () => {
|
|
1302
|
+
materializeForSchedules.mockResolvedValue(
|
|
1303
|
+
materialization({
|
|
1304
|
+
schedule,
|
|
1305
|
+
shifts: [
|
|
1306
|
+
shiftOn(
|
|
1307
|
+
schedule,
|
|
1308
|
+
me.toString(),
|
|
1309
|
+
"2026-09-02T07:00:00Z",
|
|
1310
|
+
"2026-09-02T15:00:00Z",
|
|
1311
|
+
),
|
|
1312
|
+
],
|
|
1313
|
+
truncated: true,
|
|
1314
|
+
}),
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
const outcome: FeedRenderOutcome =
|
|
1318
|
+
await OnCallCalendarFeedRenderer.render(request);
|
|
1319
|
+
|
|
1320
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1321
|
+
expect(outcome.stale).toBe(false);
|
|
1322
|
+
expect(outcome.truncated).toBe(true);
|
|
1323
|
+
expect(outcome.eventCount).toBe(1);
|
|
1324
|
+
expect(unfold(outcome.body)).toContain("Some shifts may be missing");
|
|
1325
|
+
expect(unfold(outcome.body)).toContain("Payments");
|
|
1326
|
+
expect(setBody).toHaveBeenCalledTimes(1);
|
|
1327
|
+
expect(setLastGood).not.toHaveBeenCalled();
|
|
1328
|
+
expect(
|
|
1329
|
+
await OnCallCalendarFeedCache.getLastGood(
|
|
1330
|
+
OnCallCalendarFeedRenderer.buildBodyCacheKey({
|
|
1331
|
+
request,
|
|
1332
|
+
pastDays: 2,
|
|
1333
|
+
futureDays: 7,
|
|
1334
|
+
schedules: [schedule],
|
|
1335
|
+
now: NOW,
|
|
1336
|
+
}),
|
|
1337
|
+
),
|
|
1338
|
+
).toBeNull();
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// -- MAX_EVENTS ---------------------------------------------------------------------
|
|
1343
|
+
|
|
1344
|
+
describe("OnCallCalendarFeedRenderer.render (MAX_EVENTS shrink)", () => {
|
|
1345
|
+
test("a feed that would exceed MAX_EVENTS is shortened by whole days, says so, and is flagged truncated", async () => {
|
|
1346
|
+
const schedule: ScheduleFixture = scheduleFixture({ projectId });
|
|
1347
|
+
installSchedules([schedule]);
|
|
1348
|
+
candidateIds.mockResolvedValue([schedule.id]);
|
|
1349
|
+
|
|
1350
|
+
/* 600 ten-minute shifts per day across the ten-day window = 6,000. */
|
|
1351
|
+
const shifts: Array<MaterializedShift> = [];
|
|
1352
|
+
const dayMs: number = 24 * 60 * 60 * 1000;
|
|
1353
|
+
const stepMs: number = 144 * 1000;
|
|
1354
|
+
|
|
1355
|
+
for (let day: number = 0; day < 10; day++) {
|
|
1356
|
+
for (let index: number = 0; index < 600; index++) {
|
|
1357
|
+
const start: Date = new Date(
|
|
1358
|
+
FEED_START.getTime() + day * dayMs + index * stepMs,
|
|
1359
|
+
);
|
|
1360
|
+
shifts.push(
|
|
1361
|
+
shiftOn(
|
|
1362
|
+
schedule,
|
|
1363
|
+
me.toString(),
|
|
1364
|
+
start.toISOString(),
|
|
1365
|
+
new Date(start.getTime() + stepMs).toISOString(),
|
|
1366
|
+
),
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
materializeForSchedules.mockResolvedValue(
|
|
1372
|
+
materialization({ schedule, shifts }),
|
|
1373
|
+
);
|
|
1374
|
+
|
|
1375
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1376
|
+
personalRequest({ projectId, userId: me }),
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1380
|
+
expect(outcome.truncated).toBe(true);
|
|
1381
|
+
expect(outcome.eventCount).toBeLessThanOrEqual(MAX_EVENTS);
|
|
1382
|
+
expect(outcome.eventCount).toBeGreaterThan(0);
|
|
1383
|
+
expect(unfold(outcome.body)).toContain("Shortened to");
|
|
1384
|
+
expect(unfold(outcome.body)).toContain(`${MAX_EVENTS} events`);
|
|
1385
|
+
/* A shrunk feed is still complete for the days it covers: last-good. */
|
|
1386
|
+
expect(setLastGood).toHaveBeenCalledTimes(1);
|
|
1387
|
+
}, 60000);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// -- Schedule feed ------------------------------------------------------------------
|
|
1391
|
+
|
|
1392
|
+
describe("OnCallCalendarFeedRenderer.render (schedule feed)", () => {
|
|
1393
|
+
let schedule: ScheduleFixture;
|
|
1394
|
+
|
|
1395
|
+
beforeEach(() => {
|
|
1396
|
+
schedule = scheduleFixture({ projectId, name: "Payments" });
|
|
1397
|
+
installSchedules([schedule]);
|
|
1398
|
+
materializeForSchedules.mockResolvedValue(
|
|
1399
|
+
materialization({
|
|
1400
|
+
schedule,
|
|
1401
|
+
shifts: [
|
|
1402
|
+
shiftOn(
|
|
1403
|
+
schedule,
|
|
1404
|
+
"user-a",
|
|
1405
|
+
"2026-09-01T07:00:00Z",
|
|
1406
|
+
"2026-09-01T15:00:00Z",
|
|
1407
|
+
),
|
|
1408
|
+
shiftOn(
|
|
1409
|
+
schedule,
|
|
1410
|
+
"user-b",
|
|
1411
|
+
"2026-09-02T07:00:00Z",
|
|
1412
|
+
"2026-09-02T15:00:00Z",
|
|
1413
|
+
),
|
|
1414
|
+
],
|
|
1415
|
+
layerProps: [alwaysOnLayer(["user-a", "user-b"], FEED_START)],
|
|
1416
|
+
}),
|
|
1417
|
+
);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test("a schedule that no longer exists gets an empty calendar", async () => {
|
|
1421
|
+
installSchedules([]);
|
|
1422
|
+
|
|
1423
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1424
|
+
scheduleRequest({ projectId, scheduleId: ObjectID.generate() }),
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1428
|
+
expect(outcome.reason).toBe(SCHEDULE_MISSING_REASON);
|
|
1429
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
test("a schedule in another project is treated as missing (the token is a project capability)", async () => {
|
|
1433
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1434
|
+
scheduleRequest({
|
|
1435
|
+
projectId: ObjectID.generate(),
|
|
1436
|
+
scheduleId: schedule.id,
|
|
1437
|
+
}),
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1441
|
+
expect(outcome.reason).toBe(SCHEDULE_MISSING_REASON);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test("below plan: empty, named after the schedule, in the schedule's zone", async () => {
|
|
1445
|
+
setBillingEnabled(true);
|
|
1446
|
+
planCheck.mockReturnValue(false);
|
|
1447
|
+
|
|
1448
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1449
|
+
scheduleRequest({ projectId, scheduleId: schedule.id }),
|
|
1450
|
+
);
|
|
1451
|
+
|
|
1452
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1453
|
+
expect(outcome.reason).toBe(PLAN_REASON);
|
|
1454
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toBe("Payments");
|
|
1455
|
+
expect(property(outcome.body, "X-WR-TIMEZONE")).toBe("Europe/Stockholm");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
test("renders everyone's shifts, named after the schedule, in the schedule's zone, with no gap events by default", async () => {
|
|
1459
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1460
|
+
scheduleRequest({ projectId, scheduleId: schedule.id }),
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1464
|
+
expect(outcome.eventCount).toBe(2);
|
|
1465
|
+
expect(summaries(outcome.body)).toEqual([
|
|
1466
|
+
"Name of user-a · On-call · Payments",
|
|
1467
|
+
"Name of user-b · On-call · Payments",
|
|
1468
|
+
]);
|
|
1469
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toBe("Payments");
|
|
1470
|
+
expect(property(outcome.body, "X-WR-TIMEZONE")).toBe("Europe/Stockholm");
|
|
1471
|
+
expect(outcome.body).not.toContain("No coverage");
|
|
1472
|
+
expect(recordEvents).toHaveBeenCalledWith(2, {
|
|
1473
|
+
[AppMetrics.ON_CALL_CALENDAR_FEED_KIND_ATTRIBUTE]: "schedule",
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("includeCoverageGaps emits a gap event for every hole inside the layers' envelope", async () => {
|
|
1478
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1479
|
+
scheduleRequest({
|
|
1480
|
+
projectId,
|
|
1481
|
+
scheduleId: schedule.id,
|
|
1482
|
+
includeCoverageGaps: true,
|
|
1483
|
+
minimumGapMinutes: 60,
|
|
1484
|
+
}),
|
|
1485
|
+
);
|
|
1486
|
+
|
|
1487
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1488
|
+
|
|
1489
|
+
const gapSummaries: Array<string> = summaries(outcome.body).filter(
|
|
1490
|
+
(summary: string): boolean => {
|
|
1491
|
+
return summary.startsWith("No coverage");
|
|
1492
|
+
},
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
/*
|
|
1496
|
+
* Three holes in a 24x7 envelope: before the first shift (1 Sep 07:00),
|
|
1497
|
+
* between the two shifts (1 Sep 15:00 -> 2 Sep 07:00) and after the LAST
|
|
1498
|
+
* shift (2 Sep 15:00 -> the end of the feed window). The dashboard's
|
|
1499
|
+
* coverage view (ScheduleShiftUtil.getCoverageGaps) stops at the last
|
|
1500
|
+
* shift, but a feed's window end is the horizon the envelope was computed
|
|
1501
|
+
* for, so a rotation that stops mid-window shows its tail as a gap too.
|
|
1502
|
+
*/
|
|
1503
|
+
expect(gapSummaries).toEqual([
|
|
1504
|
+
"No coverage · Payments",
|
|
1505
|
+
"No coverage · Payments",
|
|
1506
|
+
"No coverage · Payments",
|
|
1507
|
+
]);
|
|
1508
|
+
expect(outcome.eventCount).toBe(5);
|
|
1509
|
+
expect(outcome.truncated).toBe(false);
|
|
1510
|
+
|
|
1511
|
+
const gapBlocks: Array<Array<string>> = eventBlocks(outcome.body).filter(
|
|
1512
|
+
(block: Array<string>): boolean => {
|
|
1513
|
+
return block.some((line: string): boolean => {
|
|
1514
|
+
return line.startsWith("SUMMARY:No coverage");
|
|
1515
|
+
});
|
|
1516
|
+
},
|
|
1517
|
+
);
|
|
1518
|
+
|
|
1519
|
+
const starts: Array<string> = gapBlocks.map(
|
|
1520
|
+
(block: Array<string>): string => {
|
|
1521
|
+
return (
|
|
1522
|
+
block.find((line: string): boolean => {
|
|
1523
|
+
return line.startsWith("DTSTART:");
|
|
1524
|
+
}) || ""
|
|
1525
|
+
).slice("DTSTART:".length);
|
|
1526
|
+
},
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
expect(starts).toEqual([
|
|
1530
|
+
"20260830T000000Z",
|
|
1531
|
+
"20260901T150000Z",
|
|
1532
|
+
"20260902T150000Z",
|
|
1533
|
+
]);
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
test("a minimum gap longer than every hole emits nothing", async () => {
|
|
1537
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1538
|
+
scheduleRequest({
|
|
1539
|
+
projectId,
|
|
1540
|
+
scheduleId: schedule.id,
|
|
1541
|
+
includeCoverageGaps: true,
|
|
1542
|
+
minimumGapMinutes: 10080,
|
|
1543
|
+
}),
|
|
1544
|
+
);
|
|
1545
|
+
|
|
1546
|
+
expect(outcome.body).not.toContain("No coverage");
|
|
1547
|
+
expect(outcome.eventCount).toBe(2);
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test("without an envelope (no layers) there is nothing a layer intended to cover, so no gap events", async () => {
|
|
1551
|
+
materializeForSchedules.mockResolvedValue(
|
|
1552
|
+
materialization({
|
|
1553
|
+
schedule,
|
|
1554
|
+
shifts: [
|
|
1555
|
+
shiftOn(
|
|
1556
|
+
schedule,
|
|
1557
|
+
"user-a",
|
|
1558
|
+
"2026-09-02T07:00:00Z",
|
|
1559
|
+
"2026-09-02T15:00:00Z",
|
|
1560
|
+
),
|
|
1561
|
+
],
|
|
1562
|
+
}),
|
|
1563
|
+
);
|
|
1564
|
+
|
|
1565
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1566
|
+
scheduleRequest({
|
|
1567
|
+
projectId,
|
|
1568
|
+
scheduleId: schedule.id,
|
|
1569
|
+
includeCoverageGaps: true,
|
|
1570
|
+
}),
|
|
1571
|
+
);
|
|
1572
|
+
|
|
1573
|
+
expect(outcome.body).not.toContain("No coverage");
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
test("MAX_GAP_EVENTS caps the gap list and says so", async () => {
|
|
1577
|
+
/* One 10-minute shift every hour for the whole window: ~240 holes. */
|
|
1578
|
+
const shifts: Array<MaterializedShift> = [];
|
|
1579
|
+
const hourMs: number = 60 * 60 * 1000;
|
|
1580
|
+
|
|
1581
|
+
for (
|
|
1582
|
+
let start: number = FEED_START.getTime();
|
|
1583
|
+
start < FEED_END.getTime();
|
|
1584
|
+
start += hourMs
|
|
1585
|
+
) {
|
|
1586
|
+
shifts.push(
|
|
1587
|
+
shiftOn(
|
|
1588
|
+
schedule,
|
|
1589
|
+
"user-a",
|
|
1590
|
+
new Date(start).toISOString(),
|
|
1591
|
+
new Date(start + 10 * 60 * 1000).toISOString(),
|
|
1592
|
+
),
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
materializeForSchedules.mockResolvedValue(
|
|
1597
|
+
materialization({
|
|
1598
|
+
schedule,
|
|
1599
|
+
shifts,
|
|
1600
|
+
layerProps: [alwaysOnLayer(["user-a"], FEED_START)],
|
|
1601
|
+
}),
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1605
|
+
scheduleRequest({
|
|
1606
|
+
projectId,
|
|
1607
|
+
scheduleId: schedule.id,
|
|
1608
|
+
includeCoverageGaps: true,
|
|
1609
|
+
minimumGapMinutes: 30,
|
|
1610
|
+
}),
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
const gapCount: number = summaries(outcome.body).filter(
|
|
1614
|
+
(summary: string): boolean => {
|
|
1615
|
+
return summary.startsWith("No coverage");
|
|
1616
|
+
},
|
|
1617
|
+
).length;
|
|
1618
|
+
|
|
1619
|
+
expect(gapCount).toBe(MAX_GAP_EVENTS);
|
|
1620
|
+
expect(outcome.truncated).toBe(true);
|
|
1621
|
+
expect(unfold(outcome.body)).toContain(
|
|
1622
|
+
`first ${MAX_GAP_EVENTS} coverage gaps`,
|
|
1623
|
+
);
|
|
1624
|
+
}, 60000);
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// -- Project feed -------------------------------------------------------------------
|
|
1628
|
+
|
|
1629
|
+
describe("OnCallCalendarFeedRenderer.render (project feed)", () => {
|
|
1630
|
+
test("a project that no longer exists gets an empty calendar", async () => {
|
|
1631
|
+
projectFindOneById.mockResolvedValue(null);
|
|
1632
|
+
|
|
1633
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1634
|
+
projectRequest({ projectId }),
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1638
|
+
expect(outcome.reason).toBe(PROJECT_MISSING_REASON);
|
|
1639
|
+
expect(scheduleFindBy).not.toHaveBeenCalled();
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
test("below plan: empty, named after the project", async () => {
|
|
1643
|
+
setBillingEnabled(true);
|
|
1644
|
+
planCheck.mockReturnValue(false);
|
|
1645
|
+
|
|
1646
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1647
|
+
projectRequest({ projectId }),
|
|
1648
|
+
);
|
|
1649
|
+
|
|
1650
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1651
|
+
expect(outcome.reason).toBe(PLAN_REASON);
|
|
1652
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toContain("Acme");
|
|
1653
|
+
expect(scheduleFindBy).not.toHaveBeenCalled();
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
test("a project with no schedules gets an empty calendar", async () => {
|
|
1657
|
+
installSchedules([]);
|
|
1658
|
+
|
|
1659
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1660
|
+
projectRequest({ projectId }),
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
expect(outcome.status).toBe(FeedRenderStatus.Empty);
|
|
1664
|
+
expect(outcome.reason).toBe(NO_PROJECT_SCHEDULES_REASON);
|
|
1665
|
+
expect(materializeForSchedules).not.toHaveBeenCalled();
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
test("renders every schedule in the project together, named after the project", async () => {
|
|
1669
|
+
const payments: ScheduleFixture = scheduleFixture({
|
|
1670
|
+
projectId,
|
|
1671
|
+
name: "Payments",
|
|
1672
|
+
});
|
|
1673
|
+
const search: ScheduleFixture = scheduleFixture({
|
|
1674
|
+
projectId,
|
|
1675
|
+
name: "Search",
|
|
1676
|
+
});
|
|
1677
|
+
const elsewhere: ScheduleFixture = scheduleFixture({
|
|
1678
|
+
projectId: ObjectID.generate(),
|
|
1679
|
+
name: "Elsewhere",
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
installSchedules([payments, search, elsewhere]);
|
|
1683
|
+
|
|
1684
|
+
materializeForSchedules.mockImplementation(
|
|
1685
|
+
async (data: {
|
|
1686
|
+
scheduleIds: Array<ObjectID>;
|
|
1687
|
+
}): Promise<MaterializeResult> => {
|
|
1688
|
+
return batchedMaterialization(
|
|
1689
|
+
data.scheduleIds.map((id: ObjectID): MaterializeResult => {
|
|
1690
|
+
const schedule: ScheduleFixture =
|
|
1691
|
+
id.toString() === payments.id.toString() ? payments : search;
|
|
1692
|
+
|
|
1693
|
+
return materialization({
|
|
1694
|
+
schedule,
|
|
1695
|
+
shifts: [
|
|
1696
|
+
shiftOn(
|
|
1697
|
+
schedule,
|
|
1698
|
+
"user-a",
|
|
1699
|
+
"2026-09-02T07:00:00Z",
|
|
1700
|
+
"2026-09-02T15:00:00Z",
|
|
1701
|
+
),
|
|
1702
|
+
],
|
|
1703
|
+
projectName: "Acme",
|
|
1704
|
+
});
|
|
1705
|
+
}),
|
|
1706
|
+
);
|
|
1707
|
+
},
|
|
1708
|
+
);
|
|
1709
|
+
|
|
1710
|
+
const outcome: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1711
|
+
projectRequest({ projectId }),
|
|
1712
|
+
);
|
|
1713
|
+
|
|
1714
|
+
expect(outcome.status).toBe(FeedRenderStatus.Rendered);
|
|
1715
|
+
expect(outcome.eventCount).toBe(2);
|
|
1716
|
+
|
|
1717
|
+
/*
|
|
1718
|
+
* ONE resolver call for BOTH schedules. Each schedule used to be
|
|
1719
|
+
* materialized on its own -- seven queries apiece, the same user and
|
|
1720
|
+
* project rows read again every time, fanned out concurrently -- which a
|
|
1721
|
+
* project feed multiplies by every schedule in the project.
|
|
1722
|
+
*/
|
|
1723
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
1724
|
+
expect(
|
|
1725
|
+
(
|
|
1726
|
+
materializeForSchedules.mock.calls[0]?.[0] as {
|
|
1727
|
+
scheduleIds: Array<ObjectID>;
|
|
1728
|
+
}
|
|
1729
|
+
).scheduleIds.map((id: ObjectID) => {
|
|
1730
|
+
return id.toString();
|
|
1731
|
+
}),
|
|
1732
|
+
).toEqual([payments.id.toString(), search.id.toString()]);
|
|
1733
|
+
expect(summaries(outcome.body).sort()).toEqual([
|
|
1734
|
+
"Name of user-a · On-call · Payments",
|
|
1735
|
+
"Name of user-a · On-call · Search",
|
|
1736
|
+
]);
|
|
1737
|
+
expect(outcome.body).not.toContain("Elsewhere");
|
|
1738
|
+
expect(property(outcome.body, "X-WR-CALNAME")).toBe(
|
|
1739
|
+
"OneUptime On-Call · Acme",
|
|
1740
|
+
);
|
|
1741
|
+
expect(recordEvents).toHaveBeenCalledWith(2, {
|
|
1742
|
+
[AppMetrics.ON_CALL_CALENDAR_FEED_KIND_ATTRIBUTE]: "project",
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
const listing: CapturedFindBy = scheduleFindBy.mock
|
|
1746
|
+
.calls[0]?.[0] as CapturedFindBy;
|
|
1747
|
+
|
|
1748
|
+
expect(String(listing.query["projectId"])).toBe(projectId.toString());
|
|
1749
|
+
expect(listing.props?.isRoot).toBe(true);
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
/*
|
|
1753
|
+
* The batch fills one cache entry per schedule, so a later render only
|
|
1754
|
+
* materializes the schedules whose entries are gone -- the whole point of
|
|
1755
|
+
* caching per schedule rather than per feed.
|
|
1756
|
+
*/
|
|
1757
|
+
test("only the schedule whose cache was purged is materialized again", async () => {
|
|
1758
|
+
const payments: ScheduleFixture = scheduleFixture({
|
|
1759
|
+
projectId,
|
|
1760
|
+
name: "Payments",
|
|
1761
|
+
});
|
|
1762
|
+
const search: ScheduleFixture = scheduleFixture({
|
|
1763
|
+
projectId,
|
|
1764
|
+
name: "Search",
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
installSchedules([payments, search]);
|
|
1768
|
+
|
|
1769
|
+
materializeForSchedules.mockImplementation(
|
|
1770
|
+
async (data: {
|
|
1771
|
+
scheduleIds: Array<ObjectID>;
|
|
1772
|
+
}): Promise<MaterializeResult> => {
|
|
1773
|
+
return batchedMaterialization(
|
|
1774
|
+
data.scheduleIds.map((id: ObjectID): MaterializeResult => {
|
|
1775
|
+
const schedule: ScheduleFixture =
|
|
1776
|
+
id.toString() === payments.id.toString() ? payments : search;
|
|
1777
|
+
|
|
1778
|
+
return materialization({
|
|
1779
|
+
schedule,
|
|
1780
|
+
shifts: [
|
|
1781
|
+
shiftOn(
|
|
1782
|
+
schedule,
|
|
1783
|
+
"user-a",
|
|
1784
|
+
"2026-09-02T07:00:00Z",
|
|
1785
|
+
"2026-09-02T15:00:00Z",
|
|
1786
|
+
),
|
|
1787
|
+
],
|
|
1788
|
+
projectName: "Acme",
|
|
1789
|
+
});
|
|
1790
|
+
}),
|
|
1791
|
+
);
|
|
1792
|
+
},
|
|
1793
|
+
);
|
|
1794
|
+
|
|
1795
|
+
const first: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1796
|
+
projectRequest({ projectId }),
|
|
1797
|
+
);
|
|
1798
|
+
|
|
1799
|
+
expect(first.eventCount).toBe(2);
|
|
1800
|
+
|
|
1801
|
+
/* Drop the body cache and ONE schedule's segments. */
|
|
1802
|
+
await OnCallCalendarFeedCache.purgeForProject(projectId.toString());
|
|
1803
|
+
await OnCallCalendarFeedCache.purgeForSchedule(search.id.toString());
|
|
1804
|
+
|
|
1805
|
+
const second: FeedRenderOutcome = await OnCallCalendarFeedRenderer.render(
|
|
1806
|
+
projectRequest({ projectId }),
|
|
1807
|
+
);
|
|
1808
|
+
|
|
1809
|
+
expect(second.eventCount).toBe(2);
|
|
1810
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(2);
|
|
1811
|
+
expect(
|
|
1812
|
+
(
|
|
1813
|
+
materializeForSchedules.mock.calls[1]?.[0] as {
|
|
1814
|
+
scheduleIds: Array<ObjectID>;
|
|
1815
|
+
}
|
|
1816
|
+
).scheduleIds.map((id: ObjectID) => {
|
|
1817
|
+
return id.toString();
|
|
1818
|
+
}),
|
|
1819
|
+
).toEqual([search.id.toString()]);
|
|
1820
|
+
expect(summaries(second.body).sort()).toEqual([
|
|
1821
|
+
"Name of user-a · On-call · Payments",
|
|
1822
|
+
"Name of user-a · On-call · Search",
|
|
1823
|
+
]);
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
// -- Schedule-level cache ------------------------------------------------------------
|
|
1828
|
+
|
|
1829
|
+
describe("OnCallCalendarFeedRenderer.loadScheduleSegments", () => {
|
|
1830
|
+
test("renders once per (version, window) and hands back a JSON-safe copy on the hit", async () => {
|
|
1831
|
+
const schedule: ScheduleFixture = scheduleFixture({ projectId });
|
|
1832
|
+
|
|
1833
|
+
materializeForSchedules.mockResolvedValue(
|
|
1834
|
+
materialization({
|
|
1835
|
+
schedule,
|
|
1836
|
+
shifts: [
|
|
1837
|
+
shiftOn(
|
|
1838
|
+
schedule,
|
|
1839
|
+
"user-a",
|
|
1840
|
+
"2026-09-02T07:00:00Z",
|
|
1841
|
+
"2026-09-02T15:00:00Z",
|
|
1842
|
+
),
|
|
1843
|
+
],
|
|
1844
|
+
layerProps: [alwaysOnLayer(["user-a"], FEED_START)],
|
|
1845
|
+
lastModifiedAt: at("2026-08-15T00:00:00Z"),
|
|
1846
|
+
}),
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1849
|
+
const info: {
|
|
1850
|
+
id: ObjectID;
|
|
1851
|
+
name: string;
|
|
1852
|
+
projectId: ObjectID;
|
|
1853
|
+
shiftConfigVersion: number;
|
|
1854
|
+
timezone?: string | undefined;
|
|
1855
|
+
} = {
|
|
1856
|
+
id: schedule.id,
|
|
1857
|
+
name: schedule.name,
|
|
1858
|
+
projectId,
|
|
1859
|
+
shiftConfigVersion: 3,
|
|
1860
|
+
timezone: "Europe/Stockholm",
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
const first: CachedScheduleSegments =
|
|
1864
|
+
await OnCallCalendarFeedRenderer.loadScheduleSegments({
|
|
1865
|
+
schedule: info,
|
|
1866
|
+
windowStart: FEED_START,
|
|
1867
|
+
windowEnd: FEED_END,
|
|
1868
|
+
now: NOW,
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
const second: CachedScheduleSegments =
|
|
1872
|
+
await OnCallCalendarFeedRenderer.loadScheduleSegments({
|
|
1873
|
+
schedule: info,
|
|
1874
|
+
windowStart: FEED_START,
|
|
1875
|
+
windowEnd: FEED_END,
|
|
1876
|
+
now: NOW,
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
1880
|
+
expect(second).toEqual(first);
|
|
1881
|
+
expect(first.scheduleId).toBe(schedule.id.toString());
|
|
1882
|
+
expect(first.scheduleName).toBe("Payments");
|
|
1883
|
+
expect(first.scheduleTimezone).toBe("Europe/Stockholm");
|
|
1884
|
+
expect(first.projectId).toBe(projectId.toString());
|
|
1885
|
+
expect(first.shiftConfigVersion).toBe(3);
|
|
1886
|
+
expect(first.lastModifiedAt).toBe("2026-08-15T00:00:00.000Z");
|
|
1887
|
+
expect(first.truncated).toBe(false);
|
|
1888
|
+
expect(first.shifts).toHaveLength(1);
|
|
1889
|
+
expect(typeof first.shifts[0]?.start).toBe("string");
|
|
1890
|
+
expect(first.envelope).toEqual([
|
|
1891
|
+
{ start: FEED_START.toISOString(), end: FEED_END.toISOString() },
|
|
1892
|
+
]);
|
|
1893
|
+
expect(first.envelopeTruncated).toBe(false);
|
|
1894
|
+
expect(JSON.parse(JSON.stringify(first))).toEqual(first);
|
|
1895
|
+
|
|
1896
|
+
/* A new version is a new key. */
|
|
1897
|
+
await OnCallCalendarFeedRenderer.loadScheduleSegments({
|
|
1898
|
+
schedule: { ...info, shiftConfigVersion: 4 },
|
|
1899
|
+
windowStart: FEED_START,
|
|
1900
|
+
windowEnd: FEED_END,
|
|
1901
|
+
now: NOW,
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(2);
|
|
1905
|
+
|
|
1906
|
+
/* A different window is a new key. */
|
|
1907
|
+
await OnCallCalendarFeedRenderer.loadScheduleSegments({
|
|
1908
|
+
schedule: info,
|
|
1909
|
+
windowStart: FEED_START,
|
|
1910
|
+
windowEnd: at("2026-09-10T00:00:00Z"),
|
|
1911
|
+
now: NOW,
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(3);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
test("a legacy schedule (no timezone) caches a null timezone and the schedule's own facts fill the gaps", async () => {
|
|
1918
|
+
const schedule: ScheduleFixture = scheduleFixture({
|
|
1919
|
+
projectId,
|
|
1920
|
+
timezone: undefined,
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
materializeForSchedules.mockResolvedValue({
|
|
1924
|
+
shifts: [],
|
|
1925
|
+
truncated: false,
|
|
1926
|
+
schedules: [],
|
|
1927
|
+
users: [],
|
|
1928
|
+
generatedAt: NOW,
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
const cached: CachedScheduleSegments =
|
|
1932
|
+
await OnCallCalendarFeedRenderer.loadScheduleSegments({
|
|
1933
|
+
schedule: {
|
|
1934
|
+
id: schedule.id,
|
|
1935
|
+
name: "Legacy",
|
|
1936
|
+
projectId,
|
|
1937
|
+
shiftConfigVersion: 0,
|
|
1938
|
+
},
|
|
1939
|
+
windowStart: FEED_START,
|
|
1940
|
+
windowEnd: FEED_END,
|
|
1941
|
+
now: NOW,
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
expect(cached.scheduleTimezone).toBeNull();
|
|
1945
|
+
expect(cached.scheduleName).toBe("Legacy");
|
|
1946
|
+
expect(cached.projectName).toBeNull();
|
|
1947
|
+
expect(cached.shiftConfigVersion).toBe(0);
|
|
1948
|
+
expect(cached.lastModifiedAt).toBe(NOW.toISOString());
|
|
1949
|
+
expect(cached.envelope).toEqual([]);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// -- /my-shifts -----------------------------------------------------------------------
|
|
1954
|
+
|
|
1955
|
+
describe("OnCallCalendarFeedRenderer.materializeUserShifts", () => {
|
|
1956
|
+
let schedule: ScheduleFixture;
|
|
1957
|
+
|
|
1958
|
+
beforeEach(() => {
|
|
1959
|
+
schedule = scheduleFixture({ projectId });
|
|
1960
|
+
installSchedules([schedule]);
|
|
1961
|
+
candidateIds.mockResolvedValue([schedule.id]);
|
|
1962
|
+
materializeForSchedules.mockResolvedValue(
|
|
1963
|
+
materialization({
|
|
1964
|
+
schedule,
|
|
1965
|
+
shifts: [
|
|
1966
|
+
shiftOn(
|
|
1967
|
+
schedule,
|
|
1968
|
+
me.toString(),
|
|
1969
|
+
"2026-08-31T07:00:00Z",
|
|
1970
|
+
"2026-08-31T15:00:00Z",
|
|
1971
|
+
),
|
|
1972
|
+
shiftOn(
|
|
1973
|
+
schedule,
|
|
1974
|
+
me.toString(),
|
|
1975
|
+
"2026-09-01T10:00:00Z",
|
|
1976
|
+
"2026-09-01T18:00:00Z",
|
|
1977
|
+
),
|
|
1978
|
+
shiftOn(
|
|
1979
|
+
schedule,
|
|
1980
|
+
"someone-else",
|
|
1981
|
+
"2026-09-02T07:00:00Z",
|
|
1982
|
+
"2026-09-02T15:00:00Z",
|
|
1983
|
+
),
|
|
1984
|
+
shiftOn(
|
|
1985
|
+
schedule,
|
|
1986
|
+
me.toString(),
|
|
1987
|
+
"2026-09-03T07:00:00Z",
|
|
1988
|
+
"2026-09-03T15:00:00Z",
|
|
1989
|
+
),
|
|
1990
|
+
shiftOn(
|
|
1991
|
+
schedule,
|
|
1992
|
+
me.toString(),
|
|
1993
|
+
"2026-09-20T07:00:00Z",
|
|
1994
|
+
"2026-09-20T15:00:00Z",
|
|
1995
|
+
),
|
|
1996
|
+
],
|
|
1997
|
+
}),
|
|
1998
|
+
);
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
test("no candidate schedules: empty without touching the database", async () => {
|
|
2002
|
+
candidateIds.mockResolvedValue([]);
|
|
2003
|
+
|
|
2004
|
+
const result: UserShiftsResult =
|
|
2005
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2006
|
+
userId: me,
|
|
2007
|
+
projectIds: [projectId],
|
|
2008
|
+
from: NOW,
|
|
2009
|
+
to: at("2026-09-10T00:00:00Z"),
|
|
2010
|
+
now: NOW,
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
expect(result).toEqual({ shifts: [], truncated: false, generatedAt: NOW });
|
|
2014
|
+
expect(scheduleFindBy).not.toHaveBeenCalled();
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
test("widens the window to whole UTC days for the cache, then cuts back to [from, to), mine only, sorted, isPast re-evaluated", async () => {
|
|
2018
|
+
const result: UserShiftsResult =
|
|
2019
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2020
|
+
userId: me,
|
|
2021
|
+
projectIds: [projectId],
|
|
2022
|
+
from: NOW,
|
|
2023
|
+
to: at("2026-09-10T00:00:00Z"),
|
|
2024
|
+
now: NOW,
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
const materializeArgs: { windowStart: Date; windowEnd: Date } =
|
|
2028
|
+
materializeForSchedules.mock.calls[0]?.[0] as {
|
|
2029
|
+
windowStart: Date;
|
|
2030
|
+
windowEnd: Date;
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
expect(materializeArgs.windowStart.toISOString()).toBe(
|
|
2034
|
+
"2026-09-01T00:00:00.000Z",
|
|
2035
|
+
);
|
|
2036
|
+
expect(materializeArgs.windowEnd.toISOString()).toBe(
|
|
2037
|
+
"2026-09-10T00:00:00.000Z",
|
|
2038
|
+
);
|
|
2039
|
+
|
|
2040
|
+
expect(
|
|
2041
|
+
result.shifts.map((entry: MaterializedShift): string => {
|
|
2042
|
+
return entry.start.toISOString();
|
|
2043
|
+
}),
|
|
2044
|
+
).toEqual(["2026-09-01T10:00:00.000Z", "2026-09-03T07:00:00.000Z"]);
|
|
2045
|
+
expect(result.shifts[0]?.isPast).toBe(true);
|
|
2046
|
+
expect(result.shifts[1]?.isPast).toBe(false);
|
|
2047
|
+
expect(result.truncated).toBe(false);
|
|
2048
|
+
expect(result.generatedAt).toBe(NOW);
|
|
2049
|
+
|
|
2050
|
+
const candidates: {
|
|
2051
|
+
projectIds?: Array<ObjectID>;
|
|
2052
|
+
includeCoveringShifts: boolean;
|
|
2053
|
+
windowStart: Date;
|
|
2054
|
+
windowEnd: Date;
|
|
2055
|
+
} = candidateIds.mock.calls[0]?.[0] as {
|
|
2056
|
+
projectIds?: Array<ObjectID>;
|
|
2057
|
+
includeCoveringShifts: boolean;
|
|
2058
|
+
windowStart: Date;
|
|
2059
|
+
windowEnd: Date;
|
|
2060
|
+
};
|
|
2061
|
+
|
|
2062
|
+
expect(candidates.projectIds?.map(String)).toEqual([projectId.toString()]);
|
|
2063
|
+
expect(candidates.includeCoveringShifts).toBe(true);
|
|
2064
|
+
expect(candidates.windowStart.toISOString()).toBe(
|
|
2065
|
+
"2026-09-01T00:00:00.000Z",
|
|
2066
|
+
);
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
test("a `to` that is exactly midnight is not widened by a day", async () => {
|
|
2070
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2071
|
+
userId: me,
|
|
2072
|
+
from: at("2026-09-01T00:00:00Z"),
|
|
2073
|
+
to: at("2026-09-03T00:00:00Z"),
|
|
2074
|
+
now: NOW,
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
const materializeArgs: { windowStart: Date; windowEnd: Date } =
|
|
2078
|
+
materializeForSchedules.mock.calls[0]?.[0] as {
|
|
2079
|
+
windowStart: Date;
|
|
2080
|
+
windowEnd: Date;
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
expect(materializeArgs.windowEnd.toISOString()).toBe(
|
|
2084
|
+
"2026-09-03T00:00:00.000Z",
|
|
2085
|
+
);
|
|
2086
|
+
|
|
2087
|
+
const candidates: { projectIds?: Array<ObjectID> } = candidateIds.mock
|
|
2088
|
+
.calls[0]?.[0] as { projectIds?: Array<ObjectID> };
|
|
2089
|
+
|
|
2090
|
+
expect(candidates.projectIds).toBeUndefined();
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
test("shares the schedule-level cache with the feeds", async () => {
|
|
2094
|
+
await OnCallCalendarFeedRenderer.render(
|
|
2095
|
+
personalRequest({ projectId, userId: me }),
|
|
2096
|
+
);
|
|
2097
|
+
|
|
2098
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2099
|
+
userId: me,
|
|
2100
|
+
projectIds: [projectId],
|
|
2101
|
+
from: at("2026-08-30T00:00:00Z"),
|
|
2102
|
+
to: at("2026-09-09T00:00:00Z"),
|
|
2103
|
+
now: NOW,
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
test("truncated propagates from the schedule-level materialization", async () => {
|
|
2110
|
+
materializeForSchedules.mockResolvedValue(
|
|
2111
|
+
materialization({ schedule, shifts: [], truncated: true }),
|
|
2112
|
+
);
|
|
2113
|
+
|
|
2114
|
+
const result: UserShiftsResult =
|
|
2115
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2116
|
+
userId: me,
|
|
2117
|
+
projectIds: [projectId],
|
|
2118
|
+
from: NOW,
|
|
2119
|
+
to: at("2026-09-10T00:00:00Z"),
|
|
2120
|
+
now: NOW,
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
expect(result.truncated).toBe(true);
|
|
2124
|
+
expect(result.shifts).toEqual([]);
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
test("a candidate schedule deleted between the two reads is skipped, not rendered", async () => {
|
|
2128
|
+
candidateIds.mockResolvedValue([ObjectID.generate(), schedule.id]);
|
|
2129
|
+
|
|
2130
|
+
const result: UserShiftsResult =
|
|
2131
|
+
await OnCallCalendarFeedRenderer.materializeUserShifts({
|
|
2132
|
+
userId: me,
|
|
2133
|
+
projectIds: [projectId],
|
|
2134
|
+
from: NOW,
|
|
2135
|
+
to: at("2026-09-10T00:00:00Z"),
|
|
2136
|
+
now: NOW,
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
expect(materializeForSchedules).toHaveBeenCalledTimes(1);
|
|
2140
|
+
expect(result.shifts).toHaveLength(2);
|
|
2141
|
+
});
|
|
2142
|
+
});
|