@oneuptime/common 11.6.0 → 11.6.2
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/AnalyticsBaseModel/AnalyticsBaseModel.ts +41 -24
- package/Models/AnalyticsModels/Index.ts +4 -0
- package/Models/AnalyticsModels/KubernetesCostAllocation.ts +842 -0
- package/Models/AnalyticsModels/MetricBaselineHourly.ts +2 -0
- package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByContainer.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +5 -2
- package/Models/AnalyticsModels/NetworkFlow.ts +54 -0
- package/Models/AnalyticsModels/SloHistory.ts +240 -0
- package/Models/DatabaseModels/Index.ts +20 -0
- package/Models/DatabaseModels/NetworkDevice.ts +453 -6
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +7 -6
- package/Models/DatabaseModels/NetworkEndpoint.ts +753 -0
- package/Models/DatabaseModels/NetworkSite.ts +1155 -0
- package/Models/DatabaseModels/NetworkSiteAssignmentRule.ts +517 -0
- package/Models/DatabaseModels/NetworkSiteLink.ts +611 -0
- package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +520 -0
- package/Models/DatabaseModels/NetworkSiteType.ts +499 -0
- package/Models/DatabaseModels/ServiceLevelObjective.ts +1106 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.ts +762 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.ts +444 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.ts +443 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.ts +359 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.ts +48 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.ts +65 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.ts +88 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.ts +391 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +38 -10
- package/Server/Services/Index.ts +24 -0
- package/Server/Services/KubernetesCostAllocationService.ts +14 -0
- package/Server/Services/MetricService.ts +21 -2
- package/Server/Services/MonitorService.ts +22 -0
- package/Server/Services/NetworkDeviceService.ts +438 -2
- package/Server/Services/NetworkEndpointService.ts +414 -0
- package/Server/Services/NetworkSiteAssignmentRuleService.ts +98 -0
- package/Server/Services/NetworkSiteLinkService.ts +10 -0
- package/Server/Services/NetworkSiteService.ts +1254 -0
- package/Server/Services/NetworkSiteStatusTimelineService.ts +10 -0
- package/Server/Services/NetworkSiteTypeService.ts +10 -0
- package/Server/Services/ProjectService.ts +122 -2
- package/Server/Services/ServiceLevelObjectiveBurnRateRuleService.ts +457 -0
- package/Server/Services/ServiceLevelObjectiveOwnerTeamService.ts +10 -0
- package/Server/Services/ServiceLevelObjectiveOwnerUserService.ts +10 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +677 -0
- package/Server/Services/SloHistoryService.ts +101 -0
- package/Server/Services/UserNotificationSettingService.ts +37 -0
- package/Server/Types/AnalyticsDatabase/AggregateBy.ts +9 -0
- package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +15 -0
- package/Server/Utils/AnalyticsDatabase/Statement.ts +39 -3
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +128 -52
- package/Server/Utils/Memory.ts +9 -1
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +190 -12
- package/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
- package/Server/Utils/Monitor/DnsResolutionCache.ts +129 -0
- package/Server/Utils/Monitor/MonitorResource.ts +4 -30
- package/Server/Utils/Monitor/NetworkDeviceHydrationUtil.ts +214 -39
- package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +343 -0
- package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +251 -0
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +176 -142
- package/Server/Utils/Monitor/OuiLookupUtil.ts +49 -0
- package/Server/Utils/Monitor/SnmpInterfaceRateUtil.ts +17 -13
- package/Server/Utils/WhatsAppTemplateUtil.ts +7 -3
- package/Tests/Models/AnalyticsModels/AnalyticsBaseModel.test.ts +122 -0
- package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +135 -0
- package/Tests/Models/NetworkSiteHierarchy.test.ts +134 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +239 -0
- package/Tests/Server/Services/KubernetesCostAllocationAggregate.test.ts +338 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +142 -0
- package/Tests/Server/Services/NetworkDeviceSiteAssignment.test.ts +484 -0
- package/Tests/Server/Services/NetworkEndpointServiceUpsert.test.ts +686 -0
- package/Tests/Server/Services/NetworkSiteAssignmentRuleService.test.ts +185 -0
- package/Tests/Server/Services/NetworkSiteService.test.ts +1354 -0
- package/Tests/Server/Services/ServiceLevelObjectiveBurnRateRuleService.test.ts +1313 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +1988 -0
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +164 -3
- package/Tests/Server/Utils/AnalyticsDatabase/Statement.test.ts +22 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +503 -0
- package/Tests/Server/Utils/Encryption.test.ts +70 -0
- package/Tests/Server/Utils/FileAttachmentMarkdownUtil.test.ts +225 -0
- package/Tests/Server/Utils/JsonWebToken.test.ts +175 -0
- package/Tests/Server/Utils/LocalFile.test.ts +258 -0
- package/Tests/Server/Utils/Memory.test.ts +213 -0
- package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +30 -0
- package/Tests/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.test.ts +385 -0
- package/Tests/Server/Utils/Monitor/DnsResolutionCache.test.ts +194 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceHydrationUtil.test.ts +155 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +600 -0
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +151 -124
- package/Tests/Server/Utils/Monitor/SnmpInterfaceRateUtil.test.ts +496 -0
- package/Tests/Server/Utils/MonitorCriteriaDataExtractor.test.ts +175 -0
- package/Tests/Server/Utils/MonitorCriteriaMessageFormatter.test.ts +282 -0
- package/Tests/Server/Utils/OuiLookupUtil.test.ts +59 -0
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +117 -0
- package/Tests/Server/Utils/StackTraceParser.test.ts +370 -0
- package/Tests/Server/Utils/Stream.test.ts +87 -0
- package/Tests/Server/Utils/ValidateGlobalProviderProjectTeams.test.ts +221 -0
- package/Tests/Server/Utils/WhatsAppTemplateUtil.test.ts +199 -0
- package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +33 -0
- package/Tests/Types/Email.test.ts +198 -0
- package/Tests/Types/HashCode.test.ts +41 -0
- package/Tests/Types/Kubernetes/KubernetesObjectParser.test.ts +177 -0
- package/Tests/Types/Log/LogQueryParser.test.ts +186 -0
- package/Tests/Types/Log/LogQueryToFilter.test.ts +111 -0
- package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +141 -1
- package/Tests/Types/Monitor/MonitorType.test.ts +59 -1
- package/Tests/Types/Monitor/NetworkDeviceAlertPack.test.ts +97 -0
- package/Tests/Types/Monitor/SnmpEndpointDiscovery.test.ts +151 -0
- package/Tests/Types/Monitor/SnmpVendorTemplate.test.ts +110 -6
- package/Tests/Types/NetworkSite/DefaultNetworkSiteType.test.ts +129 -0
- package/Tests/Types/SerializableObjectDictionary.test.ts +166 -0
- package/Tests/Types/Time/RangeStartAndEndDateTime.test.ts +98 -0
- package/Tests/Types/WebsiteRequest.test.ts +230 -0
- package/Tests/UI/Components/EntityFilterRoundTrip.test.tsx +103 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableUrlState.test.tsx +612 -0
- package/Tests/UI/Components/ModelTable/FilterDataToQuery.test.ts +275 -0
- package/Tests/UI/Utils/LogExport.test.ts +159 -0
- package/Tests/UI/Utils/Navigation.test.ts +183 -0
- package/Tests/UI/Utils/TableFilterUrlState.test.ts +539 -0
- package/Tests/UI/Utils/TableViewUrlState.test.ts +272 -0
- package/Tests/Utils/Base64.test.ts +51 -0
- package/Tests/Utils/Boolean.test.ts +76 -0
- package/Tests/Utils/Crypto.test.ts +48 -0
- package/Tests/Utils/Dashboard/DashboardSloComponent.test.ts +259 -0
- package/Tests/Utils/Dashboard/ModelQueryVariableInterpolation.test.ts +162 -0
- package/Tests/Utils/Dashboard/VariableInterpolation.test.ts +197 -0
- package/Tests/Utils/Enum.test.ts +54 -0
- package/Tests/Utils/IpCanonicalUtil.test.ts +85 -0
- package/Tests/Utils/Metrics/MetricSeriesFingerprint.test.ts +160 -0
- package/Tests/Utils/Monitor/EndpointAttachmentUtil.test.ts +836 -0
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +218 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +447 -0
- package/Tests/Utils/NetworkSite/CidrMatchUtil.test.ts +440 -0
- package/Tests/Utils/NetworkSite/MaterializedPathUtil.test.ts +205 -0
- package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +221 -0
- package/Tests/Utils/NetworkSite/SiteUptimeUtil.test.ts +283 -0
- package/Tests/Utils/ObjectUtil.test.ts +30 -0
- package/Tests/Utils/Slo/SloStatusColor.test.ts +89 -0
- package/Tests/Utils/Slo/SloUtil.test.ts +1980 -0
- package/Tests/Utils/Slo/SloWidgetFormat.test.ts +605 -0
- package/Tests/Utils/TechStack.test.ts +50 -0
- package/Tests/Utils/Traces/CriticalPath.test.ts +178 -0
- package/Tests/Utils/ValueFormatter.test.ts +305 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +10 -0
- package/Types/AnalyticsDatabase/TableColumn.ts +23 -0
- package/Types/BaseDatabase/EqualTo.ts +9 -1
- package/Types/BaseDatabase/EqualToOrNull.ts +9 -1
- package/Types/BaseDatabase/NotEqual.ts +9 -1
- package/Types/Dashboard/DashboardComponentType.ts +1 -0
- package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
- package/Types/Dashboard/DashboardComponents/DashboardSloComponent.ts +42 -0
- package/Types/Dashboard/DashboardTemplates.ts +214 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Kubernetes/KubernetesCostIngest.ts +79 -0
- package/Types/Monitor/CriteriaFilter.ts +9 -0
- package/Types/Monitor/MonitorStep.ts +5 -8
- package/Types/Monitor/MonitorStepNetworkDeviceMonitor.ts +27 -5
- package/Types/Monitor/MonitorType.ts +15 -5
- package/Types/Monitor/SnmpMonitor/ArpEntry.ts +14 -0
- package/Types/Monitor/SnmpMonitor/FdbEntry.ts +18 -0
- package/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.ts +10 -3
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -5
- package/Types/Monitor/SnmpMonitor/SnmpMonitorResponse.ts +12 -0
- package/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.ts +231 -3
- package/Types/NetworkSite/DefaultNetworkSiteType.ts +20 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/Permission.ts +449 -0
- package/Types/ServiceLevelObjective/SliType.ts +6 -0
- package/Types/ServiceLevelObjective/SloMultiMonitorMode.ts +6 -0
- package/Types/ServiceLevelObjective/SloStatus.ts +9 -0
- package/Types/ServiceLevelObjective/SloWindowType.ts +6 -0
- package/Types/Telemetry/ServiceType.ts +1 -0
- package/UI/Components/Filters/EntityFilter.tsx +16 -0
- package/UI/Components/Filters/FilterViewer.tsx +22 -5
- package/UI/Components/List/List.tsx +8 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +309 -106
- package/UI/Components/ModelTable/FilterDataToQuery.ts +172 -0
- package/UI/Components/Navbar/NavBar.tsx +16 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +3 -3
- package/UI/Utils/Navigation.ts +10 -0
- package/UI/Utils/TableFilterUrlState.ts +204 -17
- package/UI/Utils/TableViewUrlState.ts +165 -0
- package/Utils/Dashboard/Components/DashboardSloComponent.ts +114 -0
- package/Utils/Dashboard/Components/Index.ts +7 -0
- package/Utils/IpCanonicalUtil.ts +134 -0
- package/Utils/Monitor/EndpointAttachmentUtil.ts +508 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +157 -5
- package/Utils/NetworkSite/CidrMatchUtil.ts +296 -0
- package/Utils/NetworkSite/MaterializedPathUtil.ts +115 -0
- package/Utils/NetworkSite/SiteStatusRollupUtil.ts +97 -0
- package/Utils/NetworkSite/SiteUptimeUtil.ts +111 -0
- package/Utils/Slo/SloStatusColor.ts +54 -0
- package/Utils/Slo/SloUtil.ts +637 -0
- package/Utils/Slo/SloWidgetFormat.ts +383 -0
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +25 -20
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/Index.js +4 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +713 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +2 -0
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js +44 -0
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/SloHistory.js +215 -0
- package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -0
- package/build/dist/Models/DatabaseModels/Index.js +20 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +458 -6
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +0 -6
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkEndpoint.js +779 -0
- package/build/dist/Models/DatabaseModels/NetworkEndpoint.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js +1180 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js +534 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteLink.js +629 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +538 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteType.js +522 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteType.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +1124 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js +769 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js +460 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js +459 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js +130 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js +31 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js +49 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js +140 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +32 -7
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +24 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/KubernetesCostAllocationService.js +12 -0
- package/build/dist/Server/Services/KubernetesCostAllocationService.js.map +1 -0
- package/build/dist/Server/Services/MetricService.js +21 -3
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +19 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +351 -1
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkEndpointService.js +319 -0
- package/build/dist/Server/Services/NetworkEndpointService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js +90 -0
- package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteLinkService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteService.js +1038 -0
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteTypeService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteTypeService.js.map +1 -0
- package/build/dist/Server/Services/ProjectService.js +98 -2
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js +358 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js +9 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js +9 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +582 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -0
- package/build/dist/Server/Services/SloHistoryService.js +77 -0
- package/build/dist/Server/Services/SloHistoryService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationSettingService.js +20 -0
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +9 -0
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +15 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +31 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +105 -52
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Memory.js +9 -1
- package/build/dist/Server/Utils/Memory.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +132 -10
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
- package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js +77 -0
- package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +21 -41
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js +152 -40
- package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +262 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +201 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +152 -120
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js +44 -0
- package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js +10 -10
- package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js.map +1 -1
- package/build/dist/Server/Utils/WhatsAppTemplateUtil.js +5 -0
- package/build/dist/Server/Utils/WhatsAppTemplateUtil.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +10 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
- package/build/dist/Types/BaseDatabase/EqualTo.js +9 -1
- package/build/dist/Types/BaseDatabase/EqualTo.js.map +1 -1
- package/build/dist/Types/BaseDatabase/EqualToOrNull.js +9 -1
- package/build/dist/Types/BaseDatabase/EqualToOrNull.js.map +1 -1
- package/build/dist/Types/BaseDatabase/NotEqual.js +9 -1
- package/build/dist/Types/BaseDatabase/NotEqual.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js +23 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js +205 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js +21 -0
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +5 -5
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js +8 -0
- package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +15 -5
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js +2 -0
- package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js.map +1 -0
- package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js +2 -0
- package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js.map +1 -0
- package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js +9 -3
- package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js +217 -3
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js.map +1 -1
- package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.js +21 -0
- package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.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/Permission.js +409 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/ServiceLevelObjective/SliType.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SliType.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloStatus.js +10 -0
- package/build/dist/Types/ServiceLevelObjective/SloStatus.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloWindowType.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SloWindowType.js.map +1 -0
- package/build/dist/Types/Telemetry/ServiceType.js +1 -0
- package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
- package/build/dist/UI/Components/Filters/EntityFilter.js +15 -0
- package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
- package/build/dist/UI/Components/Filters/FilterViewer.js +19 -4
- package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
- package/build/dist/UI/Components/List/List.js +1 -1
- package/build/dist/UI/Components/List/List.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +235 -89
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js +96 -0
- package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +14 -2
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +3 -2
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +9 -0
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/UI/Utils/TableFilterUrlState.js +159 -15
- package/build/dist/UI/Utils/TableFilterUrlState.js.map +1 -1
- package/build/dist/UI/Utils/TableViewUrlState.js +103 -0
- package/build/dist/UI/Utils/TableViewUrlState.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js +93 -0
- package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- package/build/dist/Utils/IpCanonicalUtil.js +110 -0
- package/build/dist/Utils/IpCanonicalUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js +311 -0
- package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +98 -5
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkSite/CidrMatchUtil.js +222 -0
- package/build/dist/Utils/NetworkSite/CidrMatchUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js +89 -0
- package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +60 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js +76 -0
- package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js.map +1 -0
- package/build/dist/Utils/Slo/SloStatusColor.js +18 -0
- package/build/dist/Utils/Slo/SloStatusColor.js.map +1 -0
- package/build/dist/Utils/Slo/SloUtil.js +393 -0
- package/build/dist/Utils/Slo/SloUtil.js.map +1 -0
- package/build/dist/Utils/Slo/SloWidgetFormat.js +174 -0
- package/build/dist/Utils/Slo/SloWidgetFormat.js.map +1 -0
- package/package.json +2 -2
- package/Types/Dashboard/DashboardComponents/Index.ts +0 -7
- package/build/dist/Types/Dashboard/DashboardComponents/Index.js +0 -8
- package/build/dist/Types/Dashboard/DashboardComponents/Index.js.map +0 -1
|
@@ -0,0 +1,1988 @@
|
|
|
1
|
+
import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
|
|
2
|
+
import Alert from "../../../Models/DatabaseModels/Alert";
|
|
3
|
+
import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
|
|
4
|
+
import MonitorStatus from "../../../Models/DatabaseModels/MonitorStatus";
|
|
5
|
+
import ServiceLevelObjective from "../../../Models/DatabaseModels/ServiceLevelObjective";
|
|
6
|
+
import ServiceLevelObjectiveBurnRateRule from "../../../Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule";
|
|
7
|
+
import ServiceLevelObjectiveOwnerTeam from "../../../Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam";
|
|
8
|
+
import ServiceLevelObjectiveOwnerUser from "../../../Models/DatabaseModels/ServiceLevelObjectiveOwnerUser";
|
|
9
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
10
|
+
import AlertService from "../../../Server/Services/AlertService";
|
|
11
|
+
import AlertSeverityService from "../../../Server/Services/AlertSeverityService";
|
|
12
|
+
import AlertStateTimelineService from "../../../Server/Services/AlertStateTimelineService";
|
|
13
|
+
import MonitorStatusService from "../../../Server/Services/MonitorStatusService";
|
|
14
|
+
import ServiceLevelObjectiveService from "../../../Server/Services/ServiceLevelObjectiveService";
|
|
15
|
+
import ServiceLevelObjectiveBurnRateRuleService from "../../../Server/Services/ServiceLevelObjectiveBurnRateRuleService";
|
|
16
|
+
import ServiceLevelObjectiveOwnerTeamService from "../../../Server/Services/ServiceLevelObjectiveOwnerTeamService";
|
|
17
|
+
import ServiceLevelObjectiveOwnerUserService from "../../../Server/Services/ServiceLevelObjectiveOwnerUserService";
|
|
18
|
+
import TeamMemberService from "../../../Server/Services/TeamMemberService";
|
|
19
|
+
import DatabaseConfig from "../../../Server/DatabaseConfig";
|
|
20
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
21
|
+
import DeleteBy from "../../../Server/Types/Database/DeleteBy";
|
|
22
|
+
import OwnerTableRegistry from "../../../Server/Types/Database/Permissions/OwnerTableRegistry";
|
|
23
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
24
|
+
import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
|
|
25
|
+
import URL from "../../../Types/API/URL";
|
|
26
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
27
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
28
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
29
|
+
import SloWindowType from "../../../Types/ServiceLevelObjective/SloWindowType";
|
|
30
|
+
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Contract under test for ServiceLevelObjectiveService:
|
|
34
|
+
*
|
|
35
|
+
* - a target of 0 or 100 is nonsense (no error budget / no room to burn),
|
|
36
|
+
* and the guard has to hold on update as well as create,
|
|
37
|
+
* - a brand new SLO must come out of onCreateSuccess already carrying the
|
|
38
|
+
* two canonical Google-SRE multi-window burn rate rules, with thresholds
|
|
39
|
+
* scaled to the compliance window (30 days == the textbook 14.4x / 6x),
|
|
40
|
+
* - disabling or deleting an SLO must resolve the burn-rate alerts it
|
|
41
|
+
* opened, because the evaluation worker skips SLOs it no longer sees and
|
|
42
|
+
* would otherwise leave on-call escalations open forever,
|
|
43
|
+
* - getDueSlos must keep selecting every column the evaluation worker
|
|
44
|
+
* reads - a dropped column silently produces wrong SLI math.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
48
|
+
"22222222-2222-4222-8222-222222222222",
|
|
49
|
+
);
|
|
50
|
+
const SLO_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
|
|
51
|
+
const RULE_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
|
|
52
|
+
const OTHER_RULE_ID: ObjectID = new ObjectID(
|
|
53
|
+
"44444444-4444-4444-8444-444444444444",
|
|
54
|
+
);
|
|
55
|
+
const SEVERITY_ID: ObjectID = new ObjectID(
|
|
56
|
+
"55555555-5555-4555-8555-555555555555",
|
|
57
|
+
);
|
|
58
|
+
const RESOLVED_STATE_ID: ObjectID = new ObjectID(
|
|
59
|
+
"66666666-6666-4666-8666-666666666666",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const TARGET_PERCENTAGE_ERROR_MESSAGE: string =
|
|
63
|
+
"SLO target must be greater than 0 and at most 99.999. A 100% target leaves no error budget.";
|
|
64
|
+
const WINDOW_DAYS_ERROR_MESSAGE: string =
|
|
65
|
+
"SLO window must be between 1 and 366 days.";
|
|
66
|
+
const AT_RISK_THRESHOLD_ERROR_MESSAGE: string =
|
|
67
|
+
"SLO at-risk threshold must be a percentage between 0 and 100.";
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Every column getDueSlos must hand to the evaluation worker. Enumerated here
|
|
71
|
+
* (rather than derived from the service) so that dropping one from the select
|
|
72
|
+
* fails this test instead of silently shipping.
|
|
73
|
+
*/
|
|
74
|
+
const EXPECTED_DUE_SLO_SELECT_KEYS: Array<string> = [
|
|
75
|
+
"_id",
|
|
76
|
+
"projectId",
|
|
77
|
+
"name",
|
|
78
|
+
"isEnabled",
|
|
79
|
+
"sliType",
|
|
80
|
+
"multiMonitorMode",
|
|
81
|
+
"monitors",
|
|
82
|
+
"downtimeMonitorStatuses",
|
|
83
|
+
"metricQueryConfig",
|
|
84
|
+
"targetPercentage",
|
|
85
|
+
"windowType",
|
|
86
|
+
"windowDays",
|
|
87
|
+
"timezone",
|
|
88
|
+
"atRiskThresholdPercentage",
|
|
89
|
+
"currentSliPercentage",
|
|
90
|
+
"errorBudgetRemainingPercentage",
|
|
91
|
+
"errorBudgetRemainingSeconds",
|
|
92
|
+
"errorBudgetTotalSeconds",
|
|
93
|
+
"currentBurnRate",
|
|
94
|
+
"sloStatus",
|
|
95
|
+
"statusChangeNotificationSentAt",
|
|
96
|
+
"lastEvaluatedAt",
|
|
97
|
+
"nextEvaluationAt",
|
|
98
|
+
"lastAccumulatedBucketEndAt",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
type SloFields = Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
function makeSlo(fields: SloFields): ServiceLevelObjective {
|
|
104
|
+
const slo: ServiceLevelObjective = new ServiceLevelObjective();
|
|
105
|
+
const writable: Record<string, unknown> = slo as unknown as Record<
|
|
106
|
+
string,
|
|
107
|
+
unknown
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
for (const key of Object.keys(fields)) {
|
|
111
|
+
writable[key] = fields[key];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return slo;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function makeCreateBy(fields: SloFields): CreateBy<ServiceLevelObjective> {
|
|
118
|
+
return {
|
|
119
|
+
data: makeSlo(fields),
|
|
120
|
+
props: { isRoot: true },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeUpdateBy(data: SloFields): UpdateBy<ServiceLevelObjective> {
|
|
125
|
+
return {
|
|
126
|
+
query: { _id: SLO_ID.toString() },
|
|
127
|
+
data: data,
|
|
128
|
+
props: { isRoot: true },
|
|
129
|
+
limit: 1,
|
|
130
|
+
skip: 0,
|
|
131
|
+
} as unknown as UpdateBy<ServiceLevelObjective>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function makeOnUpdate(data: SloFields): OnUpdate<ServiceLevelObjective> {
|
|
135
|
+
return {
|
|
136
|
+
updateBy: makeUpdateBy(data),
|
|
137
|
+
carryForward: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function makeMonitorStatus(
|
|
142
|
+
isOperationalState: boolean,
|
|
143
|
+
name: string,
|
|
144
|
+
): MonitorStatus {
|
|
145
|
+
const monitorStatus: MonitorStatus = new MonitorStatus();
|
|
146
|
+
monitorStatus._id = ObjectID.generate().toString();
|
|
147
|
+
monitorStatus.name = name;
|
|
148
|
+
monitorStatus.isOperationalState = isOperationalState;
|
|
149
|
+
return monitorStatus;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function makeBurnRateRule(id: ObjectID): ServiceLevelObjectiveBurnRateRule {
|
|
153
|
+
const rule: ServiceLevelObjectiveBurnRateRule =
|
|
154
|
+
new ServiceLevelObjectiveBurnRateRule();
|
|
155
|
+
rule._id = id.toString();
|
|
156
|
+
rule.id = id;
|
|
157
|
+
rule.projectId = PROJECT_ID;
|
|
158
|
+
rule.serviceLevelObjectiveId = SLO_ID;
|
|
159
|
+
return rule;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeAlert(id: ObjectID): Alert {
|
|
163
|
+
const alert: Alert = new Alert();
|
|
164
|
+
alert._id = id.toString();
|
|
165
|
+
alert.id = id;
|
|
166
|
+
alert.projectId = PROJECT_ID;
|
|
167
|
+
return alert;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeUser(id: ObjectID): User {
|
|
171
|
+
const user: User = new User();
|
|
172
|
+
user._id = id.toString();
|
|
173
|
+
user.id = id;
|
|
174
|
+
return user;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Calls the protected hook without widening the service's public surface.
|
|
178
|
+
function callHook(name: string, ...args: Array<unknown>): Promise<unknown> {
|
|
179
|
+
const hooks: Record<
|
|
180
|
+
string,
|
|
181
|
+
(...hookArgs: Array<unknown>) => Promise<unknown>
|
|
182
|
+
> = ServiceLevelObjectiveService as unknown as Record<
|
|
183
|
+
string,
|
|
184
|
+
(...hookArgs: Array<unknown>) => Promise<unknown>
|
|
185
|
+
>;
|
|
186
|
+
|
|
187
|
+
return hooks[name]!.apply(ServiceLevelObjectiveService, args);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function expectBadData(
|
|
191
|
+
promise: Promise<unknown>,
|
|
192
|
+
message: string,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
await expect(promise).rejects.toThrow(BadDataException);
|
|
195
|
+
await expect(promise).rejects.toThrow(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe("ServiceLevelObjectiveService.onBeforeCreate - target percentage guard", () => {
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
jest.spyOn(MonitorStatusService, "findBy").mockResolvedValue([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(() => {
|
|
204
|
+
jest.restoreAllMocks();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const rejectedTargets: Array<{ label: string; value: unknown }> = [
|
|
208
|
+
{ label: "0 (a target of nothing is not an objective)", value: 0 },
|
|
209
|
+
{ label: "a negative target", value: -1 },
|
|
210
|
+
{ label: "exactly 100 (leaves no error budget)", value: 100 },
|
|
211
|
+
{ label: "above 100", value: 100.001 },
|
|
212
|
+
{ label: "999", value: 999 },
|
|
213
|
+
{ label: "just above the 99.999 ceiling", value: 99.9991 },
|
|
214
|
+
{ label: "NaN", value: Number.NaN },
|
|
215
|
+
{ label: "Infinity", value: Number.POSITIVE_INFINITY },
|
|
216
|
+
{ label: "null", value: null },
|
|
217
|
+
/*
|
|
218
|
+
* Strings reach this hook for every numeric column (see the coercion
|
|
219
|
+
* describe block below), so the rejections have to hold in string form too
|
|
220
|
+
* - coercion must not become a way to smuggle an out-of-range target past
|
|
221
|
+
* the guard.
|
|
222
|
+
*/
|
|
223
|
+
{ label: 'the string "0"', value: "0" },
|
|
224
|
+
{ label: 'the string "100"', value: "100" },
|
|
225
|
+
{ label: "an empty string", value: "" },
|
|
226
|
+
{ label: "a whitespace-only string", value: " " },
|
|
227
|
+
{ label: "a non-numeric string", value: "abc" },
|
|
228
|
+
{ label: 'a string that overflows to Infinity ("1e400")', value: "1e400" },
|
|
229
|
+
{ label: "a boolean", value: true },
|
|
230
|
+
{ label: "an object", value: {} },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (const rejectedTarget of rejectedTargets) {
|
|
234
|
+
it(`rejects ${rejectedTarget.label} with the error-budget rationale`, async () => {
|
|
235
|
+
await expectBadData(
|
|
236
|
+
callHook(
|
|
237
|
+
"onBeforeCreate",
|
|
238
|
+
makeCreateBy({
|
|
239
|
+
projectId: PROJECT_ID,
|
|
240
|
+
targetPercentage: rejectedTarget.value,
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
TARGET_PERCENTAGE_ERROR_MESSAGE,
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
it("rejects a create with no targetPercentage at all - the field is required", async () => {
|
|
249
|
+
await expectBadData(
|
|
250
|
+
callHook("onBeforeCreate", makeCreateBy({ projectId: PROJECT_ID })),
|
|
251
|
+
TARGET_PERCENTAGE_ERROR_MESSAGE,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("names the error budget in the rejection so the dashboard can explain why", async () => {
|
|
256
|
+
let thrown: BadDataException | null = null;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await callHook(
|
|
260
|
+
"onBeforeCreate",
|
|
261
|
+
makeCreateBy({ projectId: PROJECT_ID, targetPercentage: 100 }),
|
|
262
|
+
);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
thrown = err as BadDataException;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
expect(thrown).toBeInstanceOf(BadDataException);
|
|
268
|
+
expect(thrown!.message).toContain("error budget");
|
|
269
|
+
expect(thrown!.message).toContain("99.999");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const acceptedTargets: Array<number> = [0.1, 50, 99, 99.9, 99.99, 99.999];
|
|
273
|
+
|
|
274
|
+
for (const acceptedTarget of acceptedTargets) {
|
|
275
|
+
it(`accepts a target of ${acceptedTarget}`, async () => {
|
|
276
|
+
await expect(
|
|
277
|
+
callHook(
|
|
278
|
+
"onBeforeCreate",
|
|
279
|
+
makeCreateBy({
|
|
280
|
+
projectId: PROJECT_ID,
|
|
281
|
+
targetPercentage: acceptedTarget,
|
|
282
|
+
}),
|
|
283
|
+
),
|
|
284
|
+
).resolves.toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it("rejects a create without a projectId before it looks at anything else", async () => {
|
|
289
|
+
await expectBadData(
|
|
290
|
+
callHook("onBeforeCreate", makeCreateBy({ targetPercentage: 99.9 })),
|
|
291
|
+
"projectId is required",
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("ServiceLevelObjectiveService.onBeforeUpdate - target percentage guard", () => {
|
|
297
|
+
afterEach(() => {
|
|
298
|
+
jest.restoreAllMocks();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const rejectedTargets: Array<{ label: string; value: unknown }> = [
|
|
302
|
+
{ label: "0", value: 0 },
|
|
303
|
+
{ label: "a negative target", value: -0.5 },
|
|
304
|
+
{ label: "exactly 100", value: 100 },
|
|
305
|
+
{ label: "above 100", value: 150 },
|
|
306
|
+
{ label: "NaN", value: Number.NaN },
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
for (const rejectedTarget of rejectedTargets) {
|
|
310
|
+
it(`rejects an update to ${rejectedTarget.label}`, async () => {
|
|
311
|
+
await expectBadData(
|
|
312
|
+
callHook(
|
|
313
|
+
"onBeforeUpdate",
|
|
314
|
+
makeUpdateBy({ targetPercentage: rejectedTarget.value }),
|
|
315
|
+
),
|
|
316
|
+
TARGET_PERCENTAGE_ERROR_MESSAGE,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const acceptedTargets: Array<number> = [0.1, 99.9, 99.999];
|
|
322
|
+
|
|
323
|
+
for (const acceptedTarget of acceptedTargets) {
|
|
324
|
+
it(`accepts an update to ${acceptedTarget}`, async () => {
|
|
325
|
+
await expect(
|
|
326
|
+
callHook(
|
|
327
|
+
"onBeforeUpdate",
|
|
328
|
+
makeUpdateBy({ targetPercentage: acceptedTarget }),
|
|
329
|
+
),
|
|
330
|
+
).resolves.toBeDefined();
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
it("does not validate the target when the update payload does not carry it", async () => {
|
|
335
|
+
await expect(
|
|
336
|
+
callHook("onBeforeUpdate", makeUpdateBy({ name: "Renamed SLO" })),
|
|
337
|
+
).resolves.toBeDefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("treats an explicit null target as 'not being updated' and does not throw", async () => {
|
|
341
|
+
/*
|
|
342
|
+
* The guard is `!== undefined && !== null`, so a null in the payload is
|
|
343
|
+
* skipped rather than rejected. Pinned so the skip stays deliberate.
|
|
344
|
+
*/
|
|
345
|
+
await expect(
|
|
346
|
+
callHook("onBeforeUpdate", makeUpdateBy({ targetPercentage: null })),
|
|
347
|
+
).resolves.toBeDefined();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("ServiceLevelObjectiveService - window days validation", () => {
|
|
352
|
+
beforeEach(() => {
|
|
353
|
+
jest.spyOn(MonitorStatusService, "findBy").mockResolvedValue([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
afterEach(() => {
|
|
357
|
+
jest.restoreAllMocks();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const rejectedWindowDays: Array<{ label: string; value: number }> = [
|
|
361
|
+
{ label: "0 days", value: 0 },
|
|
362
|
+
{ label: "a negative window", value: -7 },
|
|
363
|
+
{ label: "more than a year (367)", value: 367 },
|
|
364
|
+
{ label: "NaN", value: Number.NaN },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
for (const rejectedWindow of rejectedWindowDays) {
|
|
368
|
+
it(`rejects a Rolling window of ${rejectedWindow.label} on create`, async () => {
|
|
369
|
+
await expectBadData(
|
|
370
|
+
callHook(
|
|
371
|
+
"onBeforeCreate",
|
|
372
|
+
makeCreateBy({
|
|
373
|
+
projectId: PROJECT_ID,
|
|
374
|
+
targetPercentage: 99.9,
|
|
375
|
+
windowType: SloWindowType.Rolling,
|
|
376
|
+
windowDays: rejectedWindow.value,
|
|
377
|
+
}),
|
|
378
|
+
),
|
|
379
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it(`rejects an update to a window of ${rejectedWindow.label}`, async () => {
|
|
384
|
+
await expectBadData(
|
|
385
|
+
callHook(
|
|
386
|
+
"onBeforeUpdate",
|
|
387
|
+
makeUpdateBy({ windowDays: rejectedWindow.value }),
|
|
388
|
+
),
|
|
389
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const acceptedWindowDays: Array<number> = [1, 7, 28, 30, 90, 366];
|
|
395
|
+
|
|
396
|
+
for (const acceptedWindow of acceptedWindowDays) {
|
|
397
|
+
it(`accepts a Rolling window of ${acceptedWindow} days on create`, async () => {
|
|
398
|
+
await expect(
|
|
399
|
+
callHook(
|
|
400
|
+
"onBeforeCreate",
|
|
401
|
+
makeCreateBy({
|
|
402
|
+
projectId: PROJECT_ID,
|
|
403
|
+
targetPercentage: 99.9,
|
|
404
|
+
windowType: SloWindowType.Rolling,
|
|
405
|
+
windowDays: acceptedWindow,
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
).resolves.toBeDefined();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it(`accepts an update to a window of ${acceptedWindow} days`, async () => {
|
|
412
|
+
await expect(
|
|
413
|
+
callHook(
|
|
414
|
+
"onBeforeUpdate",
|
|
415
|
+
makeUpdateBy({ windowDays: acceptedWindow }),
|
|
416
|
+
),
|
|
417
|
+
).resolves.toBeDefined();
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it("validates the window on create when windowType is omitted, because Rolling is the default", async () => {
|
|
422
|
+
await expectBadData(
|
|
423
|
+
callHook(
|
|
424
|
+
"onBeforeCreate",
|
|
425
|
+
makeCreateBy({
|
|
426
|
+
projectId: PROJECT_ID,
|
|
427
|
+
targetPercentage: 99.9,
|
|
428
|
+
windowDays: 0,
|
|
429
|
+
}),
|
|
430
|
+
),
|
|
431
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/*
|
|
436
|
+
* Create and update must agree on what a valid window is. When create
|
|
437
|
+
* skipped this check for CalendarMonth, an out-of-range windowDays could be
|
|
438
|
+
* persisted and then never updated (onBeforeUpdate validates it
|
|
439
|
+
* unconditionally), and switching the SLO to Rolling later would evaluate
|
|
440
|
+
* against that out-of-range window.
|
|
441
|
+
*/
|
|
442
|
+
it("rejects an out-of-range windowDays on create even for a CalendarMonth SLO", async () => {
|
|
443
|
+
await expectBadData(
|
|
444
|
+
callHook(
|
|
445
|
+
"onBeforeCreate",
|
|
446
|
+
makeCreateBy({
|
|
447
|
+
projectId: PROJECT_ID,
|
|
448
|
+
targetPercentage: 99.9,
|
|
449
|
+
windowType: SloWindowType.CalendarMonth,
|
|
450
|
+
windowDays: 0,
|
|
451
|
+
}),
|
|
452
|
+
),
|
|
453
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("accepts a CalendarMonth SLO that omits windowDays entirely", async () => {
|
|
458
|
+
await expect(
|
|
459
|
+
callHook(
|
|
460
|
+
"onBeforeCreate",
|
|
461
|
+
makeCreateBy({
|
|
462
|
+
projectId: PROJECT_ID,
|
|
463
|
+
targetPercentage: 99.9,
|
|
464
|
+
windowType: SloWindowType.CalendarMonth,
|
|
465
|
+
}),
|
|
466
|
+
),
|
|
467
|
+
).resolves.toBeDefined();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("does not validate the window on create when the column is absent", async () => {
|
|
471
|
+
await expect(
|
|
472
|
+
callHook(
|
|
473
|
+
"onBeforeCreate",
|
|
474
|
+
makeCreateBy({
|
|
475
|
+
projectId: PROJECT_ID,
|
|
476
|
+
targetPercentage: 99.9,
|
|
477
|
+
windowType: SloWindowType.Rolling,
|
|
478
|
+
}),
|
|
479
|
+
),
|
|
480
|
+
).resolves.toBeDefined();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("does not validate the window when the update payload does not carry it", async () => {
|
|
484
|
+
await expect(
|
|
485
|
+
callHook("onBeforeUpdate", makeUpdateBy({ timezone: "UTC" })),
|
|
486
|
+
).resolves.toBeDefined();
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/*
|
|
491
|
+
* Regression: numeric columns arrive as STRINGS from the dashboard.
|
|
492
|
+
* "Target (%)" is a FormFieldSchemaType.Number, which renders an
|
|
493
|
+
* <input type="number"> whose onChange hands Formik `e.target.value` - a
|
|
494
|
+
* string. ModelForm.onSubmit copies values verbatim and BaseModel.fromJSON
|
|
495
|
+
* does not coerce Number/Decimal columns, so onBeforeCreate used to receive
|
|
496
|
+
* "99.9" and its strict `typeof value !== "number"` check rejected every
|
|
497
|
+
* single dashboard SLO create and edit. The validators now coerce first and
|
|
498
|
+
* write the coerced number back onto the payload, so Postgres never receives
|
|
499
|
+
* a string either.
|
|
500
|
+
*/
|
|
501
|
+
describe("ServiceLevelObjectiveService - numeric columns supplied as strings", () => {
|
|
502
|
+
beforeEach(() => {
|
|
503
|
+
jest.spyOn(MonitorStatusService, "findBy").mockResolvedValue([]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
afterEach(() => {
|
|
507
|
+
jest.restoreAllMocks();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("accepts the string target the dashboard actually sends and persists it as a number", async () => {
|
|
511
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
512
|
+
projectId: PROJECT_ID,
|
|
513
|
+
targetPercentage: "99.9",
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
517
|
+
|
|
518
|
+
expect(createBy.data.targetPercentage).toBe(99.9);
|
|
519
|
+
expect(typeof createBy.data.targetPercentage).toBe("number");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("coerces every user-writable numeric column on create, not just the target", async () => {
|
|
523
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
524
|
+
projectId: PROJECT_ID,
|
|
525
|
+
targetPercentage: "99.95",
|
|
526
|
+
windowDays: "30",
|
|
527
|
+
atRiskThresholdPercentage: "20",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
531
|
+
|
|
532
|
+
expect(createBy.data.targetPercentage).toBe(99.95);
|
|
533
|
+
expect(createBy.data.windowDays).toBe(30);
|
|
534
|
+
expect(createBy.data.atRiskThresholdPercentage).toBe(20);
|
|
535
|
+
|
|
536
|
+
expect(typeof createBy.data.targetPercentage).toBe("number");
|
|
537
|
+
expect(typeof createBy.data.windowDays).toBe("number");
|
|
538
|
+
expect(typeof createBy.data.atRiskThresholdPercentage).toBe("number");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("tolerates surrounding whitespace on a string numeric column", async () => {
|
|
542
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
543
|
+
projectId: PROJECT_ID,
|
|
544
|
+
targetPercentage: " 99.9 ",
|
|
545
|
+
windowDays: " 7 ",
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
549
|
+
|
|
550
|
+
expect(createBy.data.targetPercentage).toBe(99.9);
|
|
551
|
+
expect(createBy.data.windowDays).toBe(7);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("writes the coerced numbers back onto the update payload so Postgres never sees a string", async () => {
|
|
555
|
+
const updateBy: UpdateBy<ServiceLevelObjective> = makeUpdateBy({
|
|
556
|
+
targetPercentage: "99.5",
|
|
557
|
+
windowDays: "90",
|
|
558
|
+
atRiskThresholdPercentage: "10",
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
|
|
562
|
+
|
|
563
|
+
expect(updateBy.data.targetPercentage).toBe(99.5);
|
|
564
|
+
expect(updateBy.data.windowDays).toBe(90);
|
|
565
|
+
expect(updateBy.data.atRiskThresholdPercentage).toBe(10);
|
|
566
|
+
|
|
567
|
+
expect(typeof updateBy.data.targetPercentage).toBe("number");
|
|
568
|
+
expect(typeof updateBy.data.windowDays).toBe("number");
|
|
569
|
+
expect(typeof updateBy.data.atRiskThresholdPercentage).toBe("number");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const acceptedWindowDayStrings: Array<string> = ["1", "7", "30", "366"];
|
|
573
|
+
|
|
574
|
+
for (const acceptedWindowDayString of acceptedWindowDayStrings) {
|
|
575
|
+
it(`accepts a window of "${acceptedWindowDayString}" days as a string on create and on update`, async () => {
|
|
576
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
577
|
+
projectId: PROJECT_ID,
|
|
578
|
+
targetPercentage: 99.9,
|
|
579
|
+
windowDays: acceptedWindowDayString,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
583
|
+
expect(createBy.data.windowDays).toBe(Number(acceptedWindowDayString));
|
|
584
|
+
|
|
585
|
+
const updateBy: UpdateBy<ServiceLevelObjective> = makeUpdateBy({
|
|
586
|
+
windowDays: acceptedWindowDayString,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
|
|
590
|
+
expect(updateBy.data.windowDays).toBe(Number(acceptedWindowDayString));
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/*
|
|
595
|
+
* Coercion must not widen the accepted range: garbage and out-of-range
|
|
596
|
+
* strings still have to be rejected, on create and on update alike.
|
|
597
|
+
*/
|
|
598
|
+
const rejectedWindowDayStrings: Array<{ label: string; value: unknown }> = [
|
|
599
|
+
{ label: "an empty string", value: "" },
|
|
600
|
+
{ label: "a whitespace-only string", value: " " },
|
|
601
|
+
{ label: "a non-numeric string", value: "abc" },
|
|
602
|
+
{ label: 'the string "0"', value: "0" },
|
|
603
|
+
{ label: 'the string "367"', value: "367" },
|
|
604
|
+
{ label: "a boolean", value: false },
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
for (const rejectedWindowDayString of rejectedWindowDayStrings) {
|
|
608
|
+
it(`rejects a windowDays of ${rejectedWindowDayString.label} on create`, async () => {
|
|
609
|
+
await expectBadData(
|
|
610
|
+
callHook(
|
|
611
|
+
"onBeforeCreate",
|
|
612
|
+
makeCreateBy({
|
|
613
|
+
projectId: PROJECT_ID,
|
|
614
|
+
targetPercentage: 99.9,
|
|
615
|
+
windowDays: rejectedWindowDayString.value,
|
|
616
|
+
}),
|
|
617
|
+
),
|
|
618
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
619
|
+
);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it(`rejects a windowDays of ${rejectedWindowDayString.label} on update`, async () => {
|
|
623
|
+
await expectBadData(
|
|
624
|
+
callHook(
|
|
625
|
+
"onBeforeUpdate",
|
|
626
|
+
makeUpdateBy({ windowDays: rejectedWindowDayString.value }),
|
|
627
|
+
),
|
|
628
|
+
WINDOW_DAYS_ERROR_MESSAGE,
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const rejectedTargetStrings: Array<{ label: string; value: unknown }> = [
|
|
634
|
+
{ label: "an empty string", value: "" },
|
|
635
|
+
{ label: "a non-numeric string", value: "abc" },
|
|
636
|
+
{ label: 'the string "100"', value: "100" },
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
for (const rejectedTargetString of rejectedTargetStrings) {
|
|
640
|
+
it(`rejects a targetPercentage of ${rejectedTargetString.label} on update`, async () => {
|
|
641
|
+
await expectBadData(
|
|
642
|
+
callHook(
|
|
643
|
+
"onBeforeUpdate",
|
|
644
|
+
makeUpdateBy({ targetPercentage: rejectedTargetString.value }),
|
|
645
|
+
),
|
|
646
|
+
TARGET_PERCENTAGE_ERROR_MESSAGE,
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const rejectedAtRiskValues: Array<{ label: string; value: unknown }> = [
|
|
652
|
+
{ label: "an empty string", value: "" },
|
|
653
|
+
{ label: "a non-numeric string", value: "abc" },
|
|
654
|
+
{ label: "a negative percentage", value: -1 },
|
|
655
|
+
{ label: 'the string "-1"', value: "-1" },
|
|
656
|
+
{ label: "above 100", value: 101 },
|
|
657
|
+
{ label: 'the string "101"', value: "101" },
|
|
658
|
+
{ label: "NaN", value: Number.NaN },
|
|
659
|
+
];
|
|
660
|
+
|
|
661
|
+
for (const rejectedAtRisk of rejectedAtRiskValues) {
|
|
662
|
+
it(`rejects an at-risk threshold of ${rejectedAtRisk.label} on create`, async () => {
|
|
663
|
+
await expectBadData(
|
|
664
|
+
callHook(
|
|
665
|
+
"onBeforeCreate",
|
|
666
|
+
makeCreateBy({
|
|
667
|
+
projectId: PROJECT_ID,
|
|
668
|
+
targetPercentage: 99.9,
|
|
669
|
+
atRiskThresholdPercentage: rejectedAtRisk.value,
|
|
670
|
+
}),
|
|
671
|
+
),
|
|
672
|
+
AT_RISK_THRESHOLD_ERROR_MESSAGE,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it(`rejects an at-risk threshold of ${rejectedAtRisk.label} on update`, async () => {
|
|
677
|
+
await expectBadData(
|
|
678
|
+
callHook(
|
|
679
|
+
"onBeforeUpdate",
|
|
680
|
+
makeUpdateBy({ atRiskThresholdPercentage: rejectedAtRisk.value }),
|
|
681
|
+
),
|
|
682
|
+
AT_RISK_THRESHOLD_ERROR_MESSAGE,
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
it("leaves an at-risk threshold of 0 and 100 alone - both ends of the range are meaningful", async () => {
|
|
688
|
+
for (const boundary of [0, 100, "0", "100"]) {
|
|
689
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
690
|
+
projectId: PROJECT_ID,
|
|
691
|
+
targetPercentage: 99.9,
|
|
692
|
+
atRiskThresholdPercentage: boundary,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
696
|
+
expect(createBy.data.atRiskThresholdPercentage).toBe(Number(boundary));
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("does not touch a numeric column the payload does not carry", async () => {
|
|
701
|
+
const updateBy: UpdateBy<ServiceLevelObjective> = makeUpdateBy({
|
|
702
|
+
name: "Renamed SLO",
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
|
|
706
|
+
|
|
707
|
+
expect(updateBy.data.targetPercentage).toBeUndefined();
|
|
708
|
+
expect(updateBy.data.windowDays).toBeUndefined();
|
|
709
|
+
expect(updateBy.data.atRiskThresholdPercentage).toBeUndefined();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("leaves an already-numeric payload byte-identical", async () => {
|
|
713
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
714
|
+
projectId: PROJECT_ID,
|
|
715
|
+
targetPercentage: 99.9,
|
|
716
|
+
windowDays: 28,
|
|
717
|
+
atRiskThresholdPercentage: 25,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
|
|
721
|
+
|
|
722
|
+
expect(createBy.data.targetPercentage).toBe(99.9);
|
|
723
|
+
expect(createBy.data.windowDays).toBe(28);
|
|
724
|
+
expect(createBy.data.atRiskThresholdPercentage).toBe(25);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
/*
|
|
729
|
+
* Regression: ServiceLevelObjective must carry @OperationalResource().
|
|
730
|
+
* Two behaviors read `isOperationalResource` off the model prototype and
|
|
731
|
+
* silently no-op without it:
|
|
732
|
+
*
|
|
733
|
+
* - OwnedScopePermission.addOwnedScopeToQuery returns the query unfiltered
|
|
734
|
+
* when the model is neither an operational resource nor @OwnedThrough, so
|
|
735
|
+
* a team granted Read/Edit/DeleteServiceLevelObjective with
|
|
736
|
+
* PermissionScope.Owned would see every SLO in the project - Owned
|
|
737
|
+
* degrading to All is a permission leak, not a cosmetic bug,
|
|
738
|
+
* - DatabaseService.autoOwnerOnCreate skips non-operational models, so the
|
|
739
|
+
* creating user would never land in ServiceLevelObjectiveOwnerUser and the
|
|
740
|
+
* @OwnedThrough burn-rate-rule query would resolve to the zero ObjectID.
|
|
741
|
+
*
|
|
742
|
+
* Asserted here because there is no model-decorator test suite to host it.
|
|
743
|
+
*/
|
|
744
|
+
describe("ServiceLevelObjective model - operational resource marking", () => {
|
|
745
|
+
it("is marked @OperationalResource so Owned scope filters and the creator is auto-owned", () => {
|
|
746
|
+
expect(new ServiceLevelObjective().isOperationalResource).toBe(true);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("has owner tables registered for the auto-owner-on-create path", () => {
|
|
750
|
+
/*
|
|
751
|
+
* autoOwnerOnCreate looks the model up in OwnerTableRegistry by
|
|
752
|
+
* `this.modelType.name`, so the decorator alone is not enough - the
|
|
753
|
+
* registry entry has to exist under exactly this key.
|
|
754
|
+
*/
|
|
755
|
+
expect(ServiceLevelObjective.name).toBe("ServiceLevelObjective");
|
|
756
|
+
expect(OwnerTableRegistry.get("ServiceLevelObjective")).toBeDefined();
|
|
757
|
+
expect(OwnerTableRegistry.get("ServiceLevelObjective")!.fkColumn).toBe(
|
|
758
|
+
"serviceLevelObjectiveId",
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("ServiceLevelObjectiveService.onBeforeCreate - downtime status defaulting", () => {
|
|
764
|
+
afterEach(() => {
|
|
765
|
+
jest.restoreAllMocks();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("defaults downtimeMonitorStatuses to the project's non-operational statuses", async () => {
|
|
769
|
+
const operational: MonitorStatus = makeMonitorStatus(true, "Operational");
|
|
770
|
+
const degraded: MonitorStatus = makeMonitorStatus(false, "Degraded");
|
|
771
|
+
const down: MonitorStatus = makeMonitorStatus(false, "Down");
|
|
772
|
+
|
|
773
|
+
const findBySpy: jest.SpyInstance = jest
|
|
774
|
+
.spyOn(MonitorStatusService, "findBy")
|
|
775
|
+
.mockResolvedValue([operational, degraded, down]);
|
|
776
|
+
|
|
777
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
778
|
+
projectId: PROJECT_ID,
|
|
779
|
+
targetPercentage: 99.9,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
await callHook("onBeforeCreate", createBy);
|
|
783
|
+
|
|
784
|
+
expect(createBy.data.downtimeMonitorStatuses).toEqual([degraded, down]);
|
|
785
|
+
expect(createBy.data.downtimeMonitorStatuses).not.toContain(operational);
|
|
786
|
+
|
|
787
|
+
const findByArg: {
|
|
788
|
+
query: Record<string, unknown>;
|
|
789
|
+
select: Record<string, unknown>;
|
|
790
|
+
props: Record<string, unknown>;
|
|
791
|
+
} = findBySpy.mock.calls[0]![0] as {
|
|
792
|
+
query: Record<string, unknown>;
|
|
793
|
+
select: Record<string, unknown>;
|
|
794
|
+
props: Record<string, unknown>;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// Scoped to the project, and it must read isOperationalState to filter on it.
|
|
798
|
+
expect(findByArg.query).toEqual({ projectId: PROJECT_ID });
|
|
799
|
+
expect(findByArg.select).toEqual({ _id: true, isOperationalState: true });
|
|
800
|
+
expect(findByArg.props).toEqual({ isRoot: true });
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("defaults to an empty list when every project status is operational", async () => {
|
|
804
|
+
jest
|
|
805
|
+
.spyOn(MonitorStatusService, "findBy")
|
|
806
|
+
.mockResolvedValue([makeMonitorStatus(true, "Operational")]);
|
|
807
|
+
|
|
808
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
809
|
+
projectId: PROJECT_ID,
|
|
810
|
+
targetPercentage: 99.9,
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await callHook("onBeforeCreate", createBy);
|
|
814
|
+
|
|
815
|
+
expect(createBy.data.downtimeMonitorStatuses).toEqual([]);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("leaves an explicitly chosen set of downtime statuses untouched", async () => {
|
|
819
|
+
const findBySpy: jest.SpyInstance = jest
|
|
820
|
+
.spyOn(MonitorStatusService, "findBy")
|
|
821
|
+
.mockResolvedValue([makeMonitorStatus(false, "Down")]);
|
|
822
|
+
|
|
823
|
+
const chosen: MonitorStatus = makeMonitorStatus(false, "Offline");
|
|
824
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
825
|
+
projectId: PROJECT_ID,
|
|
826
|
+
targetPercentage: 99.9,
|
|
827
|
+
downtimeMonitorStatuses: [chosen],
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
await callHook("onBeforeCreate", createBy);
|
|
831
|
+
|
|
832
|
+
expect(createBy.data.downtimeMonitorStatuses).toEqual([chosen]);
|
|
833
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("keeps an explicitly empty downtime status list, rather than re-defaulting it", async () => {
|
|
837
|
+
const findBySpy: jest.SpyInstance = jest
|
|
838
|
+
.spyOn(MonitorStatusService, "findBy")
|
|
839
|
+
.mockResolvedValue([makeMonitorStatus(false, "Down")]);
|
|
840
|
+
|
|
841
|
+
const createBy: CreateBy<ServiceLevelObjective> = makeCreateBy({
|
|
842
|
+
projectId: PROJECT_ID,
|
|
843
|
+
targetPercentage: 99.9,
|
|
844
|
+
downtimeMonitorStatuses: [],
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
await callHook("onBeforeCreate", createBy);
|
|
848
|
+
|
|
849
|
+
expect(createBy.data.downtimeMonitorStatuses).toEqual([]);
|
|
850
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
describe("ServiceLevelObjectiveService.onCreateSuccess - burn rate rule seeding math", () => {
|
|
855
|
+
let burnRateRuleCreateSpy: jest.SpyInstance;
|
|
856
|
+
let updateOneByIdSpy: jest.SpyInstance;
|
|
857
|
+
|
|
858
|
+
beforeEach(() => {
|
|
859
|
+
updateOneByIdSpy = jest
|
|
860
|
+
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
861
|
+
.mockResolvedValue(undefined);
|
|
862
|
+
|
|
863
|
+
const severity: AlertSeverity = new AlertSeverity();
|
|
864
|
+
severity._id = SEVERITY_ID.toString();
|
|
865
|
+
severity.id = SEVERITY_ID;
|
|
866
|
+
|
|
867
|
+
jest.spyOn(AlertSeverityService, "findOneBy").mockResolvedValue(severity);
|
|
868
|
+
|
|
869
|
+
burnRateRuleCreateSpy = jest
|
|
870
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "create")
|
|
871
|
+
.mockResolvedValue(makeBurnRateRule(RULE_ID));
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
afterEach(() => {
|
|
875
|
+
jest.restoreAllMocks();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
function seededRules(): Array<ServiceLevelObjectiveBurnRateRule> {
|
|
879
|
+
return burnRateRuleCreateSpy.mock.calls.map((call: Array<unknown>) => {
|
|
880
|
+
return (call[0] as CreateBy<ServiceLevelObjectiveBurnRateRule>).data;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function runOnCreateSuccess(fields: SloFields): Promise<void> {
|
|
885
|
+
const createdItem: ServiceLevelObjective = makeSlo({
|
|
886
|
+
_id: SLO_ID.toString(),
|
|
887
|
+
id: SLO_ID,
|
|
888
|
+
projectId: PROJECT_ID,
|
|
889
|
+
...fields,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const onCreate: OnCreate<ServiceLevelObjective> = {
|
|
893
|
+
createBy: makeCreateBy({ projectId: PROJECT_ID, targetPercentage: 99.9 }),
|
|
894
|
+
carryForward: null,
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
await callHook("onCreateSuccess", onCreate, createdItem);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/*
|
|
901
|
+
* Thresholds are the documented formula evaluated by hand:
|
|
902
|
+
* fast = round2(0.02 * windowHours / 1)
|
|
903
|
+
* slow = round2(0.05 * windowHours / 6)
|
|
904
|
+
* Hard-coded numbers on purpose - re-deriving the formula in the test would
|
|
905
|
+
* not catch a change to the formula itself.
|
|
906
|
+
*/
|
|
907
|
+
const seedingCases: Array<{
|
|
908
|
+
label: string;
|
|
909
|
+
fields: SloFields;
|
|
910
|
+
windowHours: number;
|
|
911
|
+
fastBurnThreshold: number;
|
|
912
|
+
slowBurnThreshold: number;
|
|
913
|
+
}> = [
|
|
914
|
+
{
|
|
915
|
+
label: "a 30 day rolling window reproduces the textbook 14.4x and 6x",
|
|
916
|
+
fields: { windowType: SloWindowType.Rolling, windowDays: 30 },
|
|
917
|
+
windowHours: 720,
|
|
918
|
+
fastBurnThreshold: 14.4,
|
|
919
|
+
slowBurnThreshold: 6,
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
label: "a 7 day rolling window scales down to 3.36x and 1.4x",
|
|
923
|
+
fields: { windowType: SloWindowType.Rolling, windowDays: 7 },
|
|
924
|
+
windowHours: 168,
|
|
925
|
+
fastBurnThreshold: 3.36,
|
|
926
|
+
slowBurnThreshold: 1.4,
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
label: "a 28 day rolling window scales to 13.44x and 5.6x",
|
|
930
|
+
fields: { windowType: SloWindowType.Rolling, windowDays: 28 },
|
|
931
|
+
windowHours: 672,
|
|
932
|
+
fastBurnThreshold: 13.44,
|
|
933
|
+
slowBurnThreshold: 5.6,
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
label: "a 90 day rolling window scales up to 43.2x and 18x",
|
|
937
|
+
fields: { windowType: SloWindowType.Rolling, windowDays: 90 },
|
|
938
|
+
windowHours: 2160,
|
|
939
|
+
fastBurnThreshold: 43.2,
|
|
940
|
+
slowBurnThreshold: 18,
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
label: "a rolling window with no windowDays falls back to 30 days",
|
|
944
|
+
fields: { windowType: SloWindowType.Rolling },
|
|
945
|
+
windowHours: 720,
|
|
946
|
+
fastBurnThreshold: 14.4,
|
|
947
|
+
slowBurnThreshold: 6,
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
label: "a CalendarMonth window uses a flat 720 hours",
|
|
951
|
+
fields: { windowType: SloWindowType.CalendarMonth },
|
|
952
|
+
windowHours: 720,
|
|
953
|
+
fastBurnThreshold: 14.4,
|
|
954
|
+
slowBurnThreshold: 6,
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
label: "a CalendarMonth window ignores a stale windowDays column",
|
|
958
|
+
fields: { windowType: SloWindowType.CalendarMonth, windowDays: 7 },
|
|
959
|
+
windowHours: 720,
|
|
960
|
+
fastBurnThreshold: 14.4,
|
|
961
|
+
slowBurnThreshold: 6,
|
|
962
|
+
},
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
for (const seedingCase of seedingCases) {
|
|
966
|
+
it(`seeds Fast burn and Slow burn so that ${seedingCase.label}`, async () => {
|
|
967
|
+
await runOnCreateSuccess(seedingCase.fields);
|
|
968
|
+
|
|
969
|
+
const rules: Array<ServiceLevelObjectiveBurnRateRule> = seededRules();
|
|
970
|
+
expect(rules).toHaveLength(2);
|
|
971
|
+
|
|
972
|
+
const fastBurn: ServiceLevelObjectiveBurnRateRule = rules[0]!;
|
|
973
|
+
expect(fastBurn.name).toBe("Fast burn");
|
|
974
|
+
expect(fastBurn.burnRateThreshold).toBe(seedingCase.fastBurnThreshold);
|
|
975
|
+
expect(fastBurn.longWindowInMinutes).toBe(60);
|
|
976
|
+
expect(fastBurn.shortWindowInMinutes).toBe(5);
|
|
977
|
+
|
|
978
|
+
const slowBurn: ServiceLevelObjectiveBurnRateRule = rules[1]!;
|
|
979
|
+
expect(slowBurn.name).toBe("Slow burn");
|
|
980
|
+
expect(slowBurn.burnRateThreshold).toBe(seedingCase.slowBurnThreshold);
|
|
981
|
+
expect(slowBurn.longWindowInMinutes).toBe(360);
|
|
982
|
+
expect(slowBurn.shortWindowInMinutes).toBe(30);
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
it("keeps every seeded threshold rounded to two decimals", async () => {
|
|
987
|
+
// 0.02 * 24 * 13 = 6.24 and 0.05 * 24 * 13 / 6 = 2.6 in exact arithmetic.
|
|
988
|
+
await runOnCreateSuccess({
|
|
989
|
+
windowType: SloWindowType.Rolling,
|
|
990
|
+
windowDays: 13,
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const rules: Array<ServiceLevelObjectiveBurnRateRule> = seededRules();
|
|
994
|
+
expect(rules[0]!.burnRateThreshold).toBe(6.24);
|
|
995
|
+
expect(rules[1]!.burnRateThreshold).toBe(2.6);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it("scopes both seeded rules to the SLO and its project, and enables them", async () => {
|
|
999
|
+
await runOnCreateSuccess({
|
|
1000
|
+
windowType: SloWindowType.Rolling,
|
|
1001
|
+
windowDays: 30,
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
for (const rule of seededRules()) {
|
|
1005
|
+
expect(rule.projectId).toEqual(PROJECT_ID);
|
|
1006
|
+
expect(rule.serviceLevelObjectiveId).toEqual(SLO_ID);
|
|
1007
|
+
expect(rule.isEnabled).toBe(true);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
for (const call of burnRateRuleCreateSpy.mock.calls) {
|
|
1011
|
+
const createBy: CreateBy<ServiceLevelObjectiveBurnRateRule> = (
|
|
1012
|
+
call as Array<unknown>
|
|
1013
|
+
)[0] as CreateBy<ServiceLevelObjectiveBurnRateRule>;
|
|
1014
|
+
expect(createBy.props).toEqual({ isRoot: true });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("stamps nextEvaluationAt to now so the worker picks the new SLO up immediately", async () => {
|
|
1019
|
+
const before: number = Date.now();
|
|
1020
|
+
await runOnCreateSuccess({
|
|
1021
|
+
windowType: SloWindowType.Rolling,
|
|
1022
|
+
windowDays: 30,
|
|
1023
|
+
});
|
|
1024
|
+
const after: number = Date.now();
|
|
1025
|
+
|
|
1026
|
+
expect(updateOneByIdSpy).toHaveBeenCalledTimes(1);
|
|
1027
|
+
|
|
1028
|
+
const updateArg: {
|
|
1029
|
+
id: ObjectID;
|
|
1030
|
+
data: { nextEvaluationAt: Date };
|
|
1031
|
+
props: Record<string, unknown>;
|
|
1032
|
+
} = updateOneByIdSpy.mock.calls[0]![0] as {
|
|
1033
|
+
id: ObjectID;
|
|
1034
|
+
data: { nextEvaluationAt: Date };
|
|
1035
|
+
props: Record<string, unknown>;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
expect(updateArg.id).toEqual(SLO_ID);
|
|
1039
|
+
expect(updateArg.props).toEqual({ isRoot: true });
|
|
1040
|
+
expect(updateArg.data.nextEvaluationAt.getTime()).toBeGreaterThanOrEqual(
|
|
1041
|
+
before,
|
|
1042
|
+
);
|
|
1043
|
+
expect(updateArg.data.nextEvaluationAt.getTime()).toBeLessThanOrEqual(
|
|
1044
|
+
after,
|
|
1045
|
+
);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("still seeds the rules when stamping nextEvaluationAt fails", async () => {
|
|
1049
|
+
updateOneByIdSpy.mockRejectedValue(new Error("db down"));
|
|
1050
|
+
|
|
1051
|
+
await expect(
|
|
1052
|
+
runOnCreateSuccess({
|
|
1053
|
+
windowType: SloWindowType.Rolling,
|
|
1054
|
+
windowDays: 30,
|
|
1055
|
+
}),
|
|
1056
|
+
).resolves.toBeUndefined();
|
|
1057
|
+
|
|
1058
|
+
expect(seededRules()).toHaveLength(2);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("does not fail the SLO create when seeding a burn rate rule throws", async () => {
|
|
1062
|
+
burnRateRuleCreateSpy.mockRejectedValue(
|
|
1063
|
+
new BadDataException("burn rule rejected"),
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
await expect(
|
|
1067
|
+
runOnCreateSuccess({
|
|
1068
|
+
windowType: SloWindowType.Rolling,
|
|
1069
|
+
windowDays: 30,
|
|
1070
|
+
}),
|
|
1071
|
+
).resolves.toBeUndefined();
|
|
1072
|
+
|
|
1073
|
+
// Both rules were still attempted - the first failure does not abort the loop.
|
|
1074
|
+
expect(burnRateRuleCreateSpy).toHaveBeenCalledTimes(2);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
/*
|
|
1078
|
+
* A transient severity lookup failure must not cost the SLO its burn rate
|
|
1079
|
+
* rules: the rules work without a default severity (the worker falls back to
|
|
1080
|
+
* the lowest order severity when it raises the alert), and nothing retries
|
|
1081
|
+
* seeding, so losing them here would leave the SLO silently unalertable.
|
|
1082
|
+
*/
|
|
1083
|
+
it("still seeds both burn rate rules when the severity lookup throws", async () => {
|
|
1084
|
+
jest
|
|
1085
|
+
.spyOn(AlertSeverityService, "findOneBy")
|
|
1086
|
+
.mockRejectedValue(new Error("severity lookup exploded"));
|
|
1087
|
+
|
|
1088
|
+
await expect(
|
|
1089
|
+
runOnCreateSuccess({
|
|
1090
|
+
windowType: SloWindowType.Rolling,
|
|
1091
|
+
windowDays: 30,
|
|
1092
|
+
}),
|
|
1093
|
+
).resolves.toBeUndefined();
|
|
1094
|
+
|
|
1095
|
+
expect(burnRateRuleCreateSpy).toHaveBeenCalledTimes(2);
|
|
1096
|
+
|
|
1097
|
+
for (const call of burnRateRuleCreateSpy.mock.calls) {
|
|
1098
|
+
expect(
|
|
1099
|
+
(call[0] as { data: ServiceLevelObjectiveBurnRateRule }).data
|
|
1100
|
+
.alertSeverityId,
|
|
1101
|
+
).toBeUndefined();
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("returns the created item unchanged", async () => {
|
|
1106
|
+
const createdItem: ServiceLevelObjective = makeSlo({
|
|
1107
|
+
_id: SLO_ID.toString(),
|
|
1108
|
+
id: SLO_ID,
|
|
1109
|
+
projectId: PROJECT_ID,
|
|
1110
|
+
windowType: SloWindowType.Rolling,
|
|
1111
|
+
windowDays: 30,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const returned: unknown = await callHook(
|
|
1115
|
+
"onCreateSuccess",
|
|
1116
|
+
{ createBy: makeCreateBy({}), carryForward: null },
|
|
1117
|
+
createdItem,
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
expect(returned).toBe(createdItem);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it("skips seeding entirely when the created row has no id or projectId", async () => {
|
|
1124
|
+
const createdItem: ServiceLevelObjective = makeSlo({
|
|
1125
|
+
projectId: PROJECT_ID,
|
|
1126
|
+
windowType: SloWindowType.Rolling,
|
|
1127
|
+
windowDays: 30,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
await callHook(
|
|
1131
|
+
"onCreateSuccess",
|
|
1132
|
+
{ createBy: makeCreateBy({}), carryForward: null },
|
|
1133
|
+
createdItem,
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
expect(burnRateRuleCreateSpy).not.toHaveBeenCalled();
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
describe("ServiceLevelObjectiveService.onCreateSuccess - default alert severity", () => {
|
|
1141
|
+
let burnRateRuleCreateSpy: jest.SpyInstance;
|
|
1142
|
+
|
|
1143
|
+
beforeEach(() => {
|
|
1144
|
+
jest
|
|
1145
|
+
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
1146
|
+
.mockResolvedValue(undefined);
|
|
1147
|
+
|
|
1148
|
+
burnRateRuleCreateSpy = jest
|
|
1149
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "create")
|
|
1150
|
+
.mockResolvedValue(makeBurnRateRule(RULE_ID));
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
afterEach(() => {
|
|
1154
|
+
jest.restoreAllMocks();
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
async function runOnCreateSuccess(): Promise<void> {
|
|
1158
|
+
const createdItem: ServiceLevelObjective = makeSlo({
|
|
1159
|
+
_id: SLO_ID.toString(),
|
|
1160
|
+
id: SLO_ID,
|
|
1161
|
+
projectId: PROJECT_ID,
|
|
1162
|
+
windowType: SloWindowType.Rolling,
|
|
1163
|
+
windowDays: 30,
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
await callHook(
|
|
1167
|
+
"onCreateSuccess",
|
|
1168
|
+
{ createBy: makeCreateBy({}), carryForward: null },
|
|
1169
|
+
createdItem,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
it("picks the project's lowest-order (most severe) severity for both rules", async () => {
|
|
1174
|
+
const severity: AlertSeverity = new AlertSeverity();
|
|
1175
|
+
severity._id = SEVERITY_ID.toString();
|
|
1176
|
+
severity.id = SEVERITY_ID;
|
|
1177
|
+
|
|
1178
|
+
const findOneBySpy: jest.SpyInstance = jest
|
|
1179
|
+
.spyOn(AlertSeverityService, "findOneBy")
|
|
1180
|
+
.mockResolvedValue(severity);
|
|
1181
|
+
|
|
1182
|
+
await runOnCreateSuccess();
|
|
1183
|
+
|
|
1184
|
+
const findOneByArg: {
|
|
1185
|
+
query: Record<string, unknown>;
|
|
1186
|
+
sort: Record<string, unknown>;
|
|
1187
|
+
props: Record<string, unknown>;
|
|
1188
|
+
} = findOneBySpy.mock.calls[0]![0] as {
|
|
1189
|
+
query: Record<string, unknown>;
|
|
1190
|
+
sort: Record<string, unknown>;
|
|
1191
|
+
props: Record<string, unknown>;
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
expect(findOneByArg.query).toEqual({ projectId: PROJECT_ID });
|
|
1195
|
+
expect(findOneByArg.sort).toEqual({ order: SortOrder.Ascending });
|
|
1196
|
+
expect(findOneByArg.props).toEqual({ isRoot: true });
|
|
1197
|
+
|
|
1198
|
+
// Looked up once for the pair, not once per rule.
|
|
1199
|
+
expect(findOneBySpy).toHaveBeenCalledTimes(1);
|
|
1200
|
+
|
|
1201
|
+
for (const call of burnRateRuleCreateSpy.mock.calls) {
|
|
1202
|
+
const createBy: CreateBy<ServiceLevelObjectiveBurnRateRule> = (
|
|
1203
|
+
call as Array<unknown>
|
|
1204
|
+
)[0] as CreateBy<ServiceLevelObjectiveBurnRateRule>;
|
|
1205
|
+
expect(createBy.data.alertSeverityId).toEqual(SEVERITY_ID);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it("still seeds both rules with no severity when the project has none", async () => {
|
|
1210
|
+
jest.spyOn(AlertSeverityService, "findOneBy").mockResolvedValue(null);
|
|
1211
|
+
|
|
1212
|
+
await expect(runOnCreateSuccess()).resolves.toBeUndefined();
|
|
1213
|
+
|
|
1214
|
+
expect(burnRateRuleCreateSpy).toHaveBeenCalledTimes(2);
|
|
1215
|
+
|
|
1216
|
+
for (const call of burnRateRuleCreateSpy.mock.calls) {
|
|
1217
|
+
const createBy: CreateBy<ServiceLevelObjectiveBurnRateRule> = (
|
|
1218
|
+
call as Array<unknown>
|
|
1219
|
+
)[0] as CreateBy<ServiceLevelObjectiveBurnRateRule>;
|
|
1220
|
+
expect(createBy.data.alertSeverityId).toBeUndefined();
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
describe("ServiceLevelObjectiveService.onUpdateSuccess", () => {
|
|
1226
|
+
let resolveAlertsSpy: jest.SpyInstance;
|
|
1227
|
+
let updateOneByIdSpy: jest.SpyInstance;
|
|
1228
|
+
let findOneByIdSpy: jest.SpyInstance;
|
|
1229
|
+
|
|
1230
|
+
beforeEach(() => {
|
|
1231
|
+
resolveAlertsSpy = jest
|
|
1232
|
+
.spyOn(ServiceLevelObjectiveService, "resolveOpenBurnRateAlertsForSlo")
|
|
1233
|
+
.mockResolvedValue(undefined);
|
|
1234
|
+
|
|
1235
|
+
updateOneByIdSpy = jest
|
|
1236
|
+
.spyOn(ServiceLevelObjectiveService, "updateOneById")
|
|
1237
|
+
.mockResolvedValue(undefined);
|
|
1238
|
+
|
|
1239
|
+
findOneByIdSpy = jest
|
|
1240
|
+
.spyOn(ServiceLevelObjectiveService, "findOneById")
|
|
1241
|
+
.mockResolvedValue(makeSlo({ projectId: PROJECT_ID }));
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
afterEach(() => {
|
|
1245
|
+
jest.restoreAllMocks();
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("resolves the open burn rate alerts of every SLO that was just disabled", async () => {
|
|
1249
|
+
await callHook("onUpdateSuccess", makeOnUpdate({ isEnabled: false }), [
|
|
1250
|
+
SLO_ID,
|
|
1251
|
+
OTHER_RULE_ID,
|
|
1252
|
+
]);
|
|
1253
|
+
|
|
1254
|
+
expect(resolveAlertsSpy).toHaveBeenCalledTimes(2);
|
|
1255
|
+
expect(resolveAlertsSpy).toHaveBeenNthCalledWith(1, {
|
|
1256
|
+
sloId: SLO_ID,
|
|
1257
|
+
projectId: PROJECT_ID,
|
|
1258
|
+
});
|
|
1259
|
+
expect(resolveAlertsSpy).toHaveBeenNthCalledWith(2, {
|
|
1260
|
+
sloId: OTHER_RULE_ID,
|
|
1261
|
+
projectId: PROJECT_ID,
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("does not resolve alerts when the SLO is being enabled", async () => {
|
|
1266
|
+
await callHook("onUpdateSuccess", makeOnUpdate({ isEnabled: true }), [
|
|
1267
|
+
SLO_ID,
|
|
1268
|
+
]);
|
|
1269
|
+
|
|
1270
|
+
expect(resolveAlertsSpy).not.toHaveBeenCalled();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("skips alert resolution for a disabled SLO row it cannot re-read", async () => {
|
|
1274
|
+
findOneByIdSpy.mockResolvedValue(null);
|
|
1275
|
+
|
|
1276
|
+
await callHook("onUpdateSuccess", makeOnUpdate({ isEnabled: false }), [
|
|
1277
|
+
SLO_ID,
|
|
1278
|
+
]);
|
|
1279
|
+
|
|
1280
|
+
expect(resolveAlertsSpy).not.toHaveBeenCalled();
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("swallows a failure while resolving alerts for one disabled SLO and continues", async () => {
|
|
1284
|
+
resolveAlertsSpy
|
|
1285
|
+
.mockRejectedValueOnce(new Error("alert service down"))
|
|
1286
|
+
.mockResolvedValueOnce(undefined);
|
|
1287
|
+
|
|
1288
|
+
await expect(
|
|
1289
|
+
callHook("onUpdateSuccess", makeOnUpdate({ isEnabled: false }), [
|
|
1290
|
+
SLO_ID,
|
|
1291
|
+
OTHER_RULE_ID,
|
|
1292
|
+
]),
|
|
1293
|
+
).resolves.toBeDefined();
|
|
1294
|
+
|
|
1295
|
+
expect(resolveAlertsSpy).toHaveBeenCalledTimes(2);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
const evaluationConfigFields: Array<{ label: string; data: SloFields }> = [
|
|
1299
|
+
{ label: "targetPercentage", data: { targetPercentage: 99.5 } },
|
|
1300
|
+
{ label: "windowDays", data: { windowDays: 7 } },
|
|
1301
|
+
{
|
|
1302
|
+
label: "windowType",
|
|
1303
|
+
data: { windowType: SloWindowType.CalendarMonth },
|
|
1304
|
+
},
|
|
1305
|
+
{ label: "monitors", data: { monitors: [] } },
|
|
1306
|
+
];
|
|
1307
|
+
|
|
1308
|
+
async function expectForcedReEvaluation(data: SloFields): Promise<void> {
|
|
1309
|
+
const before: number = Date.now();
|
|
1310
|
+
await callHook("onUpdateSuccess", makeOnUpdate(data), [SLO_ID]);
|
|
1311
|
+
const after: number = Date.now();
|
|
1312
|
+
|
|
1313
|
+
expect(updateOneByIdSpy).toHaveBeenCalledTimes(1);
|
|
1314
|
+
|
|
1315
|
+
const updateArg: {
|
|
1316
|
+
id: ObjectID;
|
|
1317
|
+
data: { nextEvaluationAt: Date };
|
|
1318
|
+
props: Record<string, unknown>;
|
|
1319
|
+
} = updateOneByIdSpy.mock.calls[0]![0] as {
|
|
1320
|
+
id: ObjectID;
|
|
1321
|
+
data: { nextEvaluationAt: Date };
|
|
1322
|
+
props: Record<string, unknown>;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
expect(updateArg.id).toEqual(SLO_ID);
|
|
1326
|
+
expect(updateArg.props).toEqual({ isRoot: true });
|
|
1327
|
+
expect(updateArg.data.nextEvaluationAt.getTime()).toBeGreaterThanOrEqual(
|
|
1328
|
+
before,
|
|
1329
|
+
);
|
|
1330
|
+
expect(updateArg.data.nextEvaluationAt.getTime()).toBeLessThanOrEqual(
|
|
1331
|
+
after,
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
for (const evaluationConfigField of evaluationConfigFields) {
|
|
1336
|
+
it(`forces re-evaluation when ${evaluationConfigField.label} changes`, async () => {
|
|
1337
|
+
await expectForcedReEvaluation(evaluationConfigField.data);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
it("forces re-evaluation for every updated SLO id, not just the first", async () => {
|
|
1342
|
+
await callHook("onUpdateSuccess", makeOnUpdate({ windowDays: 90 }), [
|
|
1343
|
+
SLO_ID,
|
|
1344
|
+
OTHER_RULE_ID,
|
|
1345
|
+
]);
|
|
1346
|
+
|
|
1347
|
+
expect(updateOneByIdSpy).toHaveBeenCalledTimes(2);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it("neither resolves alerts nor forces re-evaluation for an unrelated field change", async () => {
|
|
1351
|
+
await callHook(
|
|
1352
|
+
"onUpdateSuccess",
|
|
1353
|
+
makeOnUpdate({ description: "just a doc tweak" }),
|
|
1354
|
+
[SLO_ID],
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1357
|
+
expect(resolveAlertsSpy).not.toHaveBeenCalled();
|
|
1358
|
+
expect(updateOneByIdSpy).not.toHaveBeenCalled();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it("swallows a failed re-evaluation stamp instead of failing the update", async () => {
|
|
1362
|
+
updateOneByIdSpy.mockRejectedValue(new Error("db down"));
|
|
1363
|
+
|
|
1364
|
+
await expect(
|
|
1365
|
+
callHook("onUpdateSuccess", makeOnUpdate({ targetPercentage: 99.5 }), [
|
|
1366
|
+
SLO_ID,
|
|
1367
|
+
]),
|
|
1368
|
+
).resolves.toBeDefined();
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it("both resolves alerts and forces re-evaluation when a disable is bundled with a config change", async () => {
|
|
1372
|
+
await callHook(
|
|
1373
|
+
"onUpdateSuccess",
|
|
1374
|
+
makeOnUpdate({ isEnabled: false, windowDays: 7 }),
|
|
1375
|
+
[SLO_ID],
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
expect(resolveAlertsSpy).toHaveBeenCalledTimes(1);
|
|
1379
|
+
expect(updateOneByIdSpy).toHaveBeenCalledTimes(1);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it("returns the onUpdate it was given", async () => {
|
|
1383
|
+
const onUpdate: OnUpdate<ServiceLevelObjective> = makeOnUpdate({
|
|
1384
|
+
name: "unchanged",
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
const returned: unknown = await callHook("onUpdateSuccess", onUpdate, [
|
|
1388
|
+
SLO_ID,
|
|
1389
|
+
]);
|
|
1390
|
+
|
|
1391
|
+
expect(returned).toBe(onUpdate);
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
describe("ServiceLevelObjectiveService.onBeforeDelete", () => {
|
|
1396
|
+
let resolveAlertsSpy: jest.SpyInstance;
|
|
1397
|
+
let findBySpy: jest.SpyInstance;
|
|
1398
|
+
|
|
1399
|
+
beforeEach(() => {
|
|
1400
|
+
resolveAlertsSpy = jest
|
|
1401
|
+
.spyOn(ServiceLevelObjectiveService, "resolveOpenBurnRateAlertsForSlo")
|
|
1402
|
+
.mockResolvedValue(undefined);
|
|
1403
|
+
|
|
1404
|
+
findBySpy = jest.spyOn(ServiceLevelObjectiveService, "findBy");
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
afterEach(() => {
|
|
1408
|
+
jest.restoreAllMocks();
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
function makeDeleteBy(): DeleteBy<ServiceLevelObjective> {
|
|
1412
|
+
return {
|
|
1413
|
+
query: { projectId: PROJECT_ID },
|
|
1414
|
+
props: { isRoot: true },
|
|
1415
|
+
limit: 10,
|
|
1416
|
+
skip: 0,
|
|
1417
|
+
} as unknown as DeleteBy<ServiceLevelObjective>;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
it("resolves the open burn rate alerts of every SLO about to be deleted", async () => {
|
|
1421
|
+
const first: ServiceLevelObjective = makeSlo({
|
|
1422
|
+
_id: SLO_ID.toString(),
|
|
1423
|
+
id: SLO_ID,
|
|
1424
|
+
projectId: PROJECT_ID,
|
|
1425
|
+
});
|
|
1426
|
+
const second: ServiceLevelObjective = makeSlo({
|
|
1427
|
+
_id: OTHER_RULE_ID.toString(),
|
|
1428
|
+
id: OTHER_RULE_ID,
|
|
1429
|
+
projectId: PROJECT_ID,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
findBySpy.mockResolvedValue([first, second]);
|
|
1433
|
+
|
|
1434
|
+
const result: unknown = await callHook("onBeforeDelete", makeDeleteBy());
|
|
1435
|
+
|
|
1436
|
+
expect(resolveAlertsSpy).toHaveBeenCalledTimes(2);
|
|
1437
|
+
expect(resolveAlertsSpy).toHaveBeenNthCalledWith(1, {
|
|
1438
|
+
sloId: SLO_ID,
|
|
1439
|
+
projectId: PROJECT_ID,
|
|
1440
|
+
});
|
|
1441
|
+
expect(resolveAlertsSpy).toHaveBeenNthCalledWith(2, {
|
|
1442
|
+
sloId: OTHER_RULE_ID,
|
|
1443
|
+
projectId: PROJECT_ID,
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// The rows are carried forward so onDeleteSuccess can still see them.
|
|
1447
|
+
expect(
|
|
1448
|
+
(result as { carryForward: { itemsToDelete: Array<unknown> } })
|
|
1449
|
+
.carryForward.itemsToDelete,
|
|
1450
|
+
).toEqual([first, second]);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it("looks the rows up as root with the caller's own delete query", async () => {
|
|
1454
|
+
findBySpy.mockResolvedValue([]);
|
|
1455
|
+
|
|
1456
|
+
await callHook("onBeforeDelete", makeDeleteBy());
|
|
1457
|
+
|
|
1458
|
+
const findByArg: {
|
|
1459
|
+
query: Record<string, unknown>;
|
|
1460
|
+
select: Record<string, unknown>;
|
|
1461
|
+
props: Record<string, unknown>;
|
|
1462
|
+
} = findBySpy.mock.calls[0]![0] as {
|
|
1463
|
+
query: Record<string, unknown>;
|
|
1464
|
+
select: Record<string, unknown>;
|
|
1465
|
+
props: Record<string, unknown>;
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
expect(findByArg.query).toEqual({ projectId: PROJECT_ID });
|
|
1469
|
+
expect(findByArg.select).toEqual({ _id: true, projectId: true });
|
|
1470
|
+
expect(findByArg.props).toEqual({ isRoot: true });
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
it("skips rows that came back without an id or a projectId", async () => {
|
|
1474
|
+
findBySpy.mockResolvedValue([
|
|
1475
|
+
makeSlo({ projectId: PROJECT_ID }),
|
|
1476
|
+
makeSlo({ _id: SLO_ID.toString(), id: SLO_ID }),
|
|
1477
|
+
]);
|
|
1478
|
+
|
|
1479
|
+
await callHook("onBeforeDelete", makeDeleteBy());
|
|
1480
|
+
|
|
1481
|
+
expect(resolveAlertsSpy).not.toHaveBeenCalled();
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it("does not block the delete when alert resolution throws", async () => {
|
|
1485
|
+
findBySpy.mockResolvedValue([
|
|
1486
|
+
makeSlo({ _id: SLO_ID.toString(), id: SLO_ID, projectId: PROJECT_ID }),
|
|
1487
|
+
]);
|
|
1488
|
+
resolveAlertsSpy.mockRejectedValue(new Error("alert service down"));
|
|
1489
|
+
|
|
1490
|
+
await expect(
|
|
1491
|
+
callHook("onBeforeDelete", makeDeleteBy()),
|
|
1492
|
+
).resolves.toBeDefined();
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
describe("ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo", () => {
|
|
1497
|
+
afterEach(() => {
|
|
1498
|
+
jest.restoreAllMocks();
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
it("delegates to every burn rate rule of the SLO with the auto-resolve root cause", async () => {
|
|
1502
|
+
const ruleFindBySpy: jest.SpyInstance = jest
|
|
1503
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1504
|
+
.mockResolvedValue([
|
|
1505
|
+
makeBurnRateRule(RULE_ID),
|
|
1506
|
+
makeBurnRateRule(OTHER_RULE_ID),
|
|
1507
|
+
]);
|
|
1508
|
+
|
|
1509
|
+
const resolveForRuleSpy: jest.SpyInstance = jest
|
|
1510
|
+
.spyOn(
|
|
1511
|
+
ServiceLevelObjectiveBurnRateRuleService,
|
|
1512
|
+
"resolveOpenAlertsForRule",
|
|
1513
|
+
)
|
|
1514
|
+
.mockResolvedValue(undefined);
|
|
1515
|
+
|
|
1516
|
+
await ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1517
|
+
sloId: SLO_ID,
|
|
1518
|
+
projectId: PROJECT_ID,
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
const ruleFindByArg: { query: Record<string, unknown> } = ruleFindBySpy.mock
|
|
1522
|
+
.calls[0]![0] as { query: Record<string, unknown> };
|
|
1523
|
+
expect(ruleFindByArg.query).toEqual({
|
|
1524
|
+
serviceLevelObjectiveId: SLO_ID,
|
|
1525
|
+
projectId: PROJECT_ID,
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
expect(resolveForRuleSpy).toHaveBeenCalledTimes(2);
|
|
1529
|
+
expect(resolveForRuleSpy).toHaveBeenNthCalledWith(1, {
|
|
1530
|
+
serviceLevelObjectiveId: SLO_ID,
|
|
1531
|
+
burnRateRuleId: RULE_ID,
|
|
1532
|
+
projectId: PROJECT_ID,
|
|
1533
|
+
rootCause:
|
|
1534
|
+
"Alert auto-resolved because the Service Level Objective was disabled or deleted.",
|
|
1535
|
+
});
|
|
1536
|
+
expect(resolveForRuleSpy).toHaveBeenNthCalledWith(
|
|
1537
|
+
2,
|
|
1538
|
+
expect.objectContaining({ burnRateRuleId: OTHER_RULE_ID }),
|
|
1539
|
+
);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
it("continues to the next rule when one rule's resolution throws", async () => {
|
|
1543
|
+
jest
|
|
1544
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1545
|
+
.mockResolvedValue([
|
|
1546
|
+
makeBurnRateRule(RULE_ID),
|
|
1547
|
+
makeBurnRateRule(OTHER_RULE_ID),
|
|
1548
|
+
]);
|
|
1549
|
+
|
|
1550
|
+
const resolveForRuleSpy: jest.SpyInstance = jest
|
|
1551
|
+
.spyOn(
|
|
1552
|
+
ServiceLevelObjectiveBurnRateRuleService,
|
|
1553
|
+
"resolveOpenAlertsForRule",
|
|
1554
|
+
)
|
|
1555
|
+
.mockRejectedValueOnce(new Error("boom"))
|
|
1556
|
+
.mockResolvedValueOnce(undefined);
|
|
1557
|
+
|
|
1558
|
+
await expect(
|
|
1559
|
+
ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1560
|
+
sloId: SLO_ID,
|
|
1561
|
+
projectId: PROJECT_ID,
|
|
1562
|
+
}),
|
|
1563
|
+
).resolves.toBeUndefined();
|
|
1564
|
+
|
|
1565
|
+
expect(resolveForRuleSpy).toHaveBeenCalledTimes(2);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it("skips a rule row that came back without an id", async () => {
|
|
1569
|
+
jest
|
|
1570
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1571
|
+
.mockResolvedValue([new ServiceLevelObjectiveBurnRateRule()]);
|
|
1572
|
+
|
|
1573
|
+
const resolveForRuleSpy: jest.SpyInstance = jest
|
|
1574
|
+
.spyOn(
|
|
1575
|
+
ServiceLevelObjectiveBurnRateRuleService,
|
|
1576
|
+
"resolveOpenAlertsForRule",
|
|
1577
|
+
)
|
|
1578
|
+
.mockResolvedValue(undefined);
|
|
1579
|
+
|
|
1580
|
+
await ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1581
|
+
sloId: SLO_ID,
|
|
1582
|
+
projectId: PROJECT_ID,
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
expect(resolveForRuleSpy).not.toHaveBeenCalled();
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("is a no-op when the SLO has no burn rate rules", async () => {
|
|
1589
|
+
jest
|
|
1590
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1591
|
+
.mockResolvedValue([]);
|
|
1592
|
+
|
|
1593
|
+
const resolveForRuleSpy: jest.SpyInstance = jest
|
|
1594
|
+
.spyOn(
|
|
1595
|
+
ServiceLevelObjectiveBurnRateRuleService,
|
|
1596
|
+
"resolveOpenAlertsForRule",
|
|
1597
|
+
)
|
|
1598
|
+
.mockResolvedValue(undefined);
|
|
1599
|
+
|
|
1600
|
+
await ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1601
|
+
sloId: SLO_ID,
|
|
1602
|
+
projectId: PROJECT_ID,
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
expect(resolveForRuleSpy).not.toHaveBeenCalled();
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
it("queries the open alerts of each rule by that rule's exact fingerprint", async () => {
|
|
1609
|
+
jest
|
|
1610
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1611
|
+
.mockResolvedValue([makeBurnRateRule(RULE_ID)]);
|
|
1612
|
+
|
|
1613
|
+
const alertFindBySpy: jest.SpyInstance = jest
|
|
1614
|
+
.spyOn(AlertService, "findBy")
|
|
1615
|
+
.mockResolvedValue([makeAlert(ObjectID.generate())]);
|
|
1616
|
+
|
|
1617
|
+
jest
|
|
1618
|
+
.spyOn(AlertStateTimelineService, "getResolvedStateIdForProject")
|
|
1619
|
+
.mockResolvedValue(RESOLVED_STATE_ID);
|
|
1620
|
+
|
|
1621
|
+
const timelineCreateSpy: jest.SpyInstance = jest
|
|
1622
|
+
.spyOn(AlertStateTimelineService, "create")
|
|
1623
|
+
.mockResolvedValue(new AlertStateTimeline());
|
|
1624
|
+
|
|
1625
|
+
await ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1626
|
+
sloId: SLO_ID,
|
|
1627
|
+
projectId: PROJECT_ID,
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
const alertFindByArg: { query: Record<string, unknown> } = alertFindBySpy
|
|
1631
|
+
.mock.calls[0]![0] as { query: Record<string, unknown> };
|
|
1632
|
+
|
|
1633
|
+
expect(alertFindByArg.query).toEqual({
|
|
1634
|
+
projectId: PROJECT_ID,
|
|
1635
|
+
seriesFingerprint: `slo:${SLO_ID.toString()}:burn-rule:${RULE_ID.toString()}`,
|
|
1636
|
+
currentAlertState: { isResolvedState: false },
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// And it actually resolved through the AlertStateTimeline path.
|
|
1640
|
+
expect(timelineCreateSpy).toHaveBeenCalledTimes(1);
|
|
1641
|
+
const timelineArg: { data: { alertStateId: ObjectID; rootCause: string } } =
|
|
1642
|
+
timelineCreateSpy.mock.calls[0]![0] as {
|
|
1643
|
+
data: { alertStateId: ObjectID; rootCause: string };
|
|
1644
|
+
};
|
|
1645
|
+
expect(timelineArg.data.alertStateId).toEqual(RESOLVED_STATE_ID);
|
|
1646
|
+
expect(timelineArg.data.rootCause).toContain(
|
|
1647
|
+
"Service Level Objective was disabled or deleted",
|
|
1648
|
+
);
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
it("tolerates the benign same-state race without rethrowing", async () => {
|
|
1652
|
+
jest
|
|
1653
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1654
|
+
.mockResolvedValue([makeBurnRateRule(RULE_ID)]);
|
|
1655
|
+
|
|
1656
|
+
jest
|
|
1657
|
+
.spyOn(AlertService, "findBy")
|
|
1658
|
+
.mockResolvedValue([makeAlert(ObjectID.generate())]);
|
|
1659
|
+
|
|
1660
|
+
jest
|
|
1661
|
+
.spyOn(AlertStateTimelineService, "getResolvedStateIdForProject")
|
|
1662
|
+
.mockResolvedValue(RESOLVED_STATE_ID);
|
|
1663
|
+
|
|
1664
|
+
jest
|
|
1665
|
+
.spyOn(AlertStateTimelineService, "create")
|
|
1666
|
+
.mockRejectedValue(
|
|
1667
|
+
new BadDataException("Alert state cannot be same as previous state."),
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
await expect(
|
|
1671
|
+
ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1672
|
+
sloId: SLO_ID,
|
|
1673
|
+
projectId: PROJECT_ID,
|
|
1674
|
+
}),
|
|
1675
|
+
).resolves.toBeUndefined();
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it("keeps resolving the remaining open alerts when one alert throws", async () => {
|
|
1679
|
+
jest
|
|
1680
|
+
.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy")
|
|
1681
|
+
.mockResolvedValue([makeBurnRateRule(RULE_ID)]);
|
|
1682
|
+
|
|
1683
|
+
jest
|
|
1684
|
+
.spyOn(AlertService, "findBy")
|
|
1685
|
+
.mockResolvedValue([
|
|
1686
|
+
makeAlert(ObjectID.generate()),
|
|
1687
|
+
makeAlert(ObjectID.generate()),
|
|
1688
|
+
]);
|
|
1689
|
+
|
|
1690
|
+
jest
|
|
1691
|
+
.spyOn(AlertStateTimelineService, "getResolvedStateIdForProject")
|
|
1692
|
+
.mockResolvedValue(RESOLVED_STATE_ID);
|
|
1693
|
+
|
|
1694
|
+
const timelineCreateSpy: jest.SpyInstance = jest
|
|
1695
|
+
.spyOn(AlertStateTimelineService, "create")
|
|
1696
|
+
.mockRejectedValueOnce(new Error("write conflict"))
|
|
1697
|
+
.mockResolvedValueOnce(new AlertStateTimeline());
|
|
1698
|
+
|
|
1699
|
+
await expect(
|
|
1700
|
+
ServiceLevelObjectiveService.resolveOpenBurnRateAlertsForSlo({
|
|
1701
|
+
sloId: SLO_ID,
|
|
1702
|
+
projectId: PROJECT_ID,
|
|
1703
|
+
}),
|
|
1704
|
+
).resolves.toBeUndefined();
|
|
1705
|
+
|
|
1706
|
+
expect(timelineCreateSpy).toHaveBeenCalledTimes(2);
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
describe("ServiceLevelObjectiveService.getDueSlos", () => {
|
|
1711
|
+
afterEach(() => {
|
|
1712
|
+
jest.restoreAllMocks();
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
async function capturedFindAllByArg(): Promise<{
|
|
1716
|
+
query: Record<string, unknown>;
|
|
1717
|
+
select: Record<string, unknown>;
|
|
1718
|
+
sort: Record<string, unknown>;
|
|
1719
|
+
props: Record<string, unknown>;
|
|
1720
|
+
}> {
|
|
1721
|
+
const findAllBySpy: jest.SpyInstance = jest
|
|
1722
|
+
.spyOn(ServiceLevelObjectiveService, "findAllBy")
|
|
1723
|
+
.mockResolvedValue([]);
|
|
1724
|
+
|
|
1725
|
+
await ServiceLevelObjectiveService.getDueSlos();
|
|
1726
|
+
|
|
1727
|
+
return findAllBySpy.mock.calls[0]![0] as {
|
|
1728
|
+
query: Record<string, unknown>;
|
|
1729
|
+
select: Record<string, unknown>;
|
|
1730
|
+
sort: Record<string, unknown>;
|
|
1731
|
+
props: Record<string, unknown>;
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
it("only picks up enabled SLOs in active projects, oldest due first, as root", async () => {
|
|
1736
|
+
const arg: {
|
|
1737
|
+
query: Record<string, unknown>;
|
|
1738
|
+
sort: Record<string, unknown>;
|
|
1739
|
+
props: Record<string, unknown>;
|
|
1740
|
+
} = await capturedFindAllByArg();
|
|
1741
|
+
|
|
1742
|
+
expect(arg.query["isEnabled"]).toBe(true);
|
|
1743
|
+
expect(arg.sort).toEqual({ nextEvaluationAt: SortOrder.Ascending });
|
|
1744
|
+
expect(arg.props).toEqual({ isRoot: true });
|
|
1745
|
+
|
|
1746
|
+
const projectQuery: Record<string, unknown> = arg.query[
|
|
1747
|
+
"project"
|
|
1748
|
+
] as Record<string, unknown>;
|
|
1749
|
+
|
|
1750
|
+
// Spread of ProjectService.getActiveProjectStatusQuery().
|
|
1751
|
+
expect(Object.keys(projectQuery).sort()).toEqual([
|
|
1752
|
+
"paymentProviderMeteredSubscriptionStatus",
|
|
1753
|
+
"paymentProviderSubscriptionStatus",
|
|
1754
|
+
]);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
it("treats a never-evaluated SLO as due via lessThanEqualToOrNull", async () => {
|
|
1758
|
+
const before: number = Date.now();
|
|
1759
|
+
const arg: { query: Record<string, unknown> } =
|
|
1760
|
+
await capturedFindAllByArg();
|
|
1761
|
+
const after: number = Date.now();
|
|
1762
|
+
|
|
1763
|
+
const nextEvaluationAt: {
|
|
1764
|
+
type: string;
|
|
1765
|
+
getSql: (alias: string) => string;
|
|
1766
|
+
objectLiteralParameters: Record<string, Date>;
|
|
1767
|
+
} = arg.query["nextEvaluationAt"] as {
|
|
1768
|
+
type: string;
|
|
1769
|
+
getSql: (alias: string) => string;
|
|
1770
|
+
objectLiteralParameters: Record<string, Date>;
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
expect(nextEvaluationAt.type).toBe("raw");
|
|
1774
|
+
|
|
1775
|
+
const sql: string = nextEvaluationAt.getSql("nextEvaluationAt");
|
|
1776
|
+
expect(sql).toContain("nextEvaluationAt <= :");
|
|
1777
|
+
expect(sql).toContain("nextEvaluationAt IS NULL");
|
|
1778
|
+
|
|
1779
|
+
const parameterValues: Array<Date> = Object.values(
|
|
1780
|
+
nextEvaluationAt.objectLiteralParameters,
|
|
1781
|
+
);
|
|
1782
|
+
expect(parameterValues).toHaveLength(1);
|
|
1783
|
+
expect(parameterValues[0]!.getTime()).toBeGreaterThanOrEqual(before);
|
|
1784
|
+
expect(parameterValues[0]!.getTime()).toBeLessThanOrEqual(after);
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
it("selects exactly the columns the evaluation worker consumes", async () => {
|
|
1788
|
+
const arg: { select: Record<string, unknown> } =
|
|
1789
|
+
await capturedFindAllByArg();
|
|
1790
|
+
|
|
1791
|
+
expect(Object.keys(arg.select).sort()).toEqual(
|
|
1792
|
+
[...EXPECTED_DUE_SLO_SELECT_KEYS].sort(),
|
|
1793
|
+
);
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
it("expands the monitor and downtime-status relations the worker walks", async () => {
|
|
1797
|
+
const arg: { select: Record<string, unknown> } =
|
|
1798
|
+
await capturedFindAllByArg();
|
|
1799
|
+
|
|
1800
|
+
expect(arg.select["monitors"]).toEqual({ _id: true });
|
|
1801
|
+
expect(arg.select["downtimeMonitorStatuses"]).toEqual({ _id: true });
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
describe("ServiceLevelObjectiveService.findOwners", () => {
|
|
1806
|
+
afterEach(() => {
|
|
1807
|
+
jest.restoreAllMocks();
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
function mockOwners(data: {
|
|
1811
|
+
ownerUsers: Array<ServiceLevelObjectiveOwnerUser>;
|
|
1812
|
+
ownerTeams: Array<ServiceLevelObjectiveOwnerTeam>;
|
|
1813
|
+
teamUsers: Array<User>;
|
|
1814
|
+
}): jest.SpyInstance {
|
|
1815
|
+
jest
|
|
1816
|
+
.spyOn(ServiceLevelObjectiveOwnerUserService, "findBy")
|
|
1817
|
+
.mockResolvedValue(data.ownerUsers);
|
|
1818
|
+
jest
|
|
1819
|
+
.spyOn(ServiceLevelObjectiveOwnerTeamService, "findBy")
|
|
1820
|
+
.mockResolvedValue(data.ownerTeams);
|
|
1821
|
+
|
|
1822
|
+
return jest
|
|
1823
|
+
.spyOn(TeamMemberService, "getUsersInTeams")
|
|
1824
|
+
.mockResolvedValue(data.teamUsers);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function makeOwnerUser(user: User): ServiceLevelObjectiveOwnerUser {
|
|
1828
|
+
const ownerUser: ServiceLevelObjectiveOwnerUser =
|
|
1829
|
+
new ServiceLevelObjectiveOwnerUser();
|
|
1830
|
+
ownerUser._id = ObjectID.generate().toString();
|
|
1831
|
+
ownerUser.user = user;
|
|
1832
|
+
return ownerUser;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function makeOwnerTeam(teamId: ObjectID): ServiceLevelObjectiveOwnerTeam {
|
|
1836
|
+
const ownerTeam: ServiceLevelObjectiveOwnerTeam =
|
|
1837
|
+
new ServiceLevelObjectiveOwnerTeam();
|
|
1838
|
+
ownerTeam._id = ObjectID.generate().toString();
|
|
1839
|
+
ownerTeam.teamId = teamId;
|
|
1840
|
+
return ownerTeam;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
it("requires a service level objective id", async () => {
|
|
1844
|
+
await expect(
|
|
1845
|
+
ServiceLevelObjectiveService.findOwners(undefined as unknown as ObjectID),
|
|
1846
|
+
).rejects.toThrow(BadDataException);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
it("returns an empty list when the SLO has no owners at all", async () => {
|
|
1850
|
+
const getUsersInTeamsSpy: jest.SpyInstance = mockOwners({
|
|
1851
|
+
ownerUsers: [],
|
|
1852
|
+
ownerTeams: [],
|
|
1853
|
+
teamUsers: [],
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
await expect(
|
|
1857
|
+
ServiceLevelObjectiveService.findOwners(SLO_ID),
|
|
1858
|
+
).resolves.toEqual([]);
|
|
1859
|
+
|
|
1860
|
+
// No teams, so no need to touch the team membership service at all.
|
|
1861
|
+
expect(getUsersInTeamsSpy).not.toHaveBeenCalled();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("returns the directly assigned owner users", async () => {
|
|
1865
|
+
const alice: User = makeUser(ObjectID.generate());
|
|
1866
|
+
const bob: User = makeUser(ObjectID.generate());
|
|
1867
|
+
|
|
1868
|
+
mockOwners({
|
|
1869
|
+
ownerUsers: [makeOwnerUser(alice), makeOwnerUser(bob)],
|
|
1870
|
+
ownerTeams: [],
|
|
1871
|
+
teamUsers: [],
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
const owners: Array<User> =
|
|
1875
|
+
await ServiceLevelObjectiveService.findOwners(SLO_ID);
|
|
1876
|
+
|
|
1877
|
+
expect(owners).toEqual([alice, bob]);
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
it("expands owner teams into their members", async () => {
|
|
1881
|
+
const carol: User = makeUser(ObjectID.generate());
|
|
1882
|
+
const teamId: ObjectID = ObjectID.generate();
|
|
1883
|
+
|
|
1884
|
+
const getUsersInTeamsSpy: jest.SpyInstance = mockOwners({
|
|
1885
|
+
ownerUsers: [],
|
|
1886
|
+
ownerTeams: [makeOwnerTeam(teamId)],
|
|
1887
|
+
teamUsers: [carol],
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
const owners: Array<User> =
|
|
1891
|
+
await ServiceLevelObjectiveService.findOwners(SLO_ID);
|
|
1892
|
+
|
|
1893
|
+
expect(owners).toEqual([carol]);
|
|
1894
|
+
expect(getUsersInTeamsSpy).toHaveBeenCalledWith([teamId]);
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
it("dedupes a user who is both a direct owner and a member of an owner team", async () => {
|
|
1898
|
+
const sharedId: ObjectID = ObjectID.generate();
|
|
1899
|
+
const directOwner: User = makeUser(sharedId);
|
|
1900
|
+
const sameUserViaTeam: User = makeUser(sharedId);
|
|
1901
|
+
const otherTeamUser: User = makeUser(ObjectID.generate());
|
|
1902
|
+
|
|
1903
|
+
mockOwners({
|
|
1904
|
+
ownerUsers: [makeOwnerUser(directOwner)],
|
|
1905
|
+
ownerTeams: [makeOwnerTeam(ObjectID.generate())],
|
|
1906
|
+
teamUsers: [sameUserViaTeam, otherTeamUser],
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
const owners: Array<User> =
|
|
1910
|
+
await ServiceLevelObjectiveService.findOwners(SLO_ID);
|
|
1911
|
+
|
|
1912
|
+
expect(owners).toHaveLength(2);
|
|
1913
|
+
expect(owners[0]).toBe(directOwner);
|
|
1914
|
+
expect(owners[1]).toBe(otherTeamUser);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
it("passes every owner team id to the membership lookup in a single call", async () => {
|
|
1918
|
+
const firstTeamId: ObjectID = ObjectID.generate();
|
|
1919
|
+
const secondTeamId: ObjectID = ObjectID.generate();
|
|
1920
|
+
|
|
1921
|
+
const getUsersInTeamsSpy: jest.SpyInstance = mockOwners({
|
|
1922
|
+
ownerUsers: [],
|
|
1923
|
+
ownerTeams: [makeOwnerTeam(firstTeamId), makeOwnerTeam(secondTeamId)],
|
|
1924
|
+
teamUsers: [],
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
await ServiceLevelObjectiveService.findOwners(SLO_ID);
|
|
1928
|
+
|
|
1929
|
+
expect(getUsersInTeamsSpy).toHaveBeenCalledTimes(1);
|
|
1930
|
+
expect(getUsersInTeamsSpy).toHaveBeenCalledWith([
|
|
1931
|
+
firstTeamId,
|
|
1932
|
+
secondTeamId,
|
|
1933
|
+
]);
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
describe("ServiceLevelObjectiveService.getSloLinkInDashboard", () => {
|
|
1938
|
+
afterEach(() => {
|
|
1939
|
+
jest.restoreAllMocks();
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it("builds the dashboard deep link from the project id and the SLO id", async () => {
|
|
1943
|
+
jest
|
|
1944
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
1945
|
+
.mockResolvedValue(URL.fromString("https://oneuptime.test/dashboard"));
|
|
1946
|
+
|
|
1947
|
+
const link: URL = await ServiceLevelObjectiveService.getSloLinkInDashboard(
|
|
1948
|
+
PROJECT_ID,
|
|
1949
|
+
SLO_ID,
|
|
1950
|
+
);
|
|
1951
|
+
|
|
1952
|
+
expect(link.toString()).toBe(
|
|
1953
|
+
`https://oneuptime.test/dashboard/${PROJECT_ID.toString()}/slos/${SLO_ID.toString()}`,
|
|
1954
|
+
);
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it("keeps the configured dashboard host, port and protocol", async () => {
|
|
1958
|
+
jest
|
|
1959
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
1960
|
+
.mockResolvedValue(URL.fromString("http://localhost:3002/dashboard"));
|
|
1961
|
+
|
|
1962
|
+
const link: URL = await ServiceLevelObjectiveService.getSloLinkInDashboard(
|
|
1963
|
+
PROJECT_ID,
|
|
1964
|
+
SLO_ID,
|
|
1965
|
+
);
|
|
1966
|
+
|
|
1967
|
+
expect(link.toString()).toBe(
|
|
1968
|
+
`http://localhost:3002/dashboard/${PROJECT_ID.toString()}/slos/${SLO_ID.toString()}`,
|
|
1969
|
+
);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
it("places the project id before the slo id so the dashboard router resolves the tenant", async () => {
|
|
1973
|
+
jest
|
|
1974
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
1975
|
+
.mockResolvedValue(URL.fromString("https://oneuptime.test/dashboard"));
|
|
1976
|
+
|
|
1977
|
+
const link: URL = await ServiceLevelObjectiveService.getSloLinkInDashboard(
|
|
1978
|
+
PROJECT_ID,
|
|
1979
|
+
SLO_ID,
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
const path: string = link.toString();
|
|
1983
|
+
expect(path.indexOf(PROJECT_ID.toString())).toBeLessThan(
|
|
1984
|
+
path.indexOf(SLO_ID.toString()),
|
|
1985
|
+
);
|
|
1986
|
+
expect(path).toContain("/slos/");
|
|
1987
|
+
});
|
|
1988
|
+
});
|