@oneuptime/common 12.0.8 → 12.0.9

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.
Files changed (769) hide show
  1. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
  2. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
  3. package/Models/DatabaseModels/AIConversation.ts +32 -0
  4. package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
  5. package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
  6. package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
  7. package/Models/DatabaseModels/Index.ts +12 -4
  8. package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +139 -9
  9. package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
  10. package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +8 -8
  11. package/Models/DatabaseModels/NetworkDevice.ts +141 -0
  12. package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
  13. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
  14. package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
  15. package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
  16. package/Models/DatabaseModels/Project.ts +78 -0
  17. package/Models/DatabaseModels/UserCall.ts +42 -0
  18. package/Models/DatabaseModels/UserEmail.ts +44 -0
  19. package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
  20. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
  21. package/Models/DatabaseModels/UserPush.ts +49 -0
  22. package/Models/DatabaseModels/UserSMS.ts +43 -0
  23. package/Models/DatabaseModels/UserTelegram.ts +59 -0
  24. package/Models/DatabaseModels/UserWebhook.ts +52 -0
  25. package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
  26. package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
  27. package/Server/API/AIChatAPI.ts +315 -1
  28. package/Server/API/DashboardAPI.ts +217 -1
  29. package/Server/API/OnCallReadinessAPI.ts +841 -0
  30. package/Server/API/TeamComplianceAPI.ts +69 -17
  31. package/Server/API/TelemetryAPI.ts +220 -12
  32. package/Server/EnvironmentConfig.ts +52 -0
  33. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
  37. package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
  38. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
  39. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
  40. package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
  41. package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
  42. package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
  43. package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
  44. package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
  45. package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
  46. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +20 -0
  47. package/Server/Infrastructure/Queue.ts +78 -13
  48. package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
  49. package/Server/Services/AIService.ts +7 -0
  50. package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
  51. package/Server/Services/AlertSeverityService.ts +63 -0
  52. package/Server/Services/DashboardService.ts +9 -10
  53. package/Server/Services/DatabaseService.ts +32 -2
  54. package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
  55. package/Server/Services/IncidentSeverityService.ts +76 -0
  56. package/Server/Services/Index.ts +12 -4
  57. package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
  58. package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +4 -4
  59. package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +89 -20
  60. package/Server/Services/LogAggregationService.ts +45 -8
  61. package/Server/Services/MetricAggregationService.ts +121 -0
  62. package/Server/Services/MetricService.ts +7 -7
  63. package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
  64. package/Server/Services/NetworkDeviceLinkService.ts +84 -0
  65. package/Server/Services/NetworkDeviceService.ts +140 -0
  66. package/Server/Services/NetworkSiteService.ts +77 -25
  67. package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
  68. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
  69. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
  70. package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
  71. package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
  72. package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
  73. package/Server/Services/OnCallReadinessService.ts +2803 -0
  74. package/Server/Services/OnCallSetupReminderService.ts +955 -0
  75. package/Server/Services/ProfileAggregationService.ts +123 -0
  76. package/Server/Services/StatusPageService.ts +9 -10
  77. package/Server/Services/TeamComplianceService.ts +429 -252
  78. package/Server/Services/UserCallService.ts +26 -1
  79. package/Server/Services/UserEmailService.ts +26 -1
  80. package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
  81. package/Server/Services/UserNotificationRuleService.ts +3812 -333
  82. package/Server/Services/UserOnCallLogService.ts +561 -48
  83. package/Server/Services/UserPushService.ts +29 -0
  84. package/Server/Services/UserService.ts +11 -0
  85. package/Server/Services/UserSmsService.ts +26 -1
  86. package/Server/Services/UserTelegramService.ts +24 -1
  87. package/Server/Services/UserWebhookService.ts +28 -1
  88. package/Server/Services/UserWhatsAppService.ts +24 -1
  89. package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
  90. package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
  91. package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
  92. package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
  93. package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
  94. package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
  95. package/Server/Types/Workflow/Components/API/Get.ts +1 -1
  96. package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
  97. package/Server/Types/Workflow/Components/API/Post.ts +1 -1
  98. package/Server/Types/Workflow/Components/API/Put.ts +1 -1
  99. package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
  100. package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
  101. package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
  102. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
  103. package/Server/Types/Workflow/Components/Email.ts +25 -7
  104. package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
  105. package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
  106. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
  107. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
  108. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
  109. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
  110. package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
  111. package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
  112. package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
  113. package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
  114. package/Server/Utils/AI/Toolbox/Index.ts +48 -0
  115. package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
  116. package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
  117. package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
  118. package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
  119. package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
  120. package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
  121. package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
  122. package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
  123. package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
  124. package/Server/Utils/ClientIp.ts +221 -0
  125. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
  126. package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
  127. package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
  128. package/Server/Utils/Express.ts +12 -17
  129. package/Server/Utils/LLM/LLMService.ts +85 -8
  130. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
  131. package/Server/Utils/SSRFProtection.ts +98 -23
  132. package/Server/Utils/StartServer.ts +12 -3
  133. package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
  134. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +25 -25
  135. package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
  136. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
  137. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
  138. package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
  139. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
  140. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
  141. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
  142. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
  143. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
  144. package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
  145. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
  146. package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
  147. package/Tests/Models/InventoryItemModel.test.ts +174 -0
  148. package/Tests/Models/InventoryItemNaming.test.ts +302 -0
  149. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
  150. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
  151. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
  152. package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
  153. package/Tests/Server/API/Helpers.ts +24 -15
  154. package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
  155. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
  156. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
  157. package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
  158. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
  159. package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
  160. package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
  161. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
  162. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
  163. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
  164. package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
  165. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
  166. package/Tests/Server/Services/{TelemetryEntityManualCreate.test.ts → InventoryItemManualCreate.test.ts} +27 -27
  167. package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
  168. package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
  169. package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
  170. package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
  171. package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
  172. package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
  173. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
  174. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
  175. package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
  176. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
  177. package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
  178. package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
  179. package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
  180. package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
  181. package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
  182. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
  183. package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
  184. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
  185. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
  186. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
  187. package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
  188. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
  189. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
  190. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
  191. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
  192. package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
  193. package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
  194. package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
  195. package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
  196. package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
  197. package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
  198. package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
  199. package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
  200. package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
  201. package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
  202. package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
  203. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
  204. package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
  205. package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
  206. package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
  207. package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
  208. package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
  209. package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
  210. package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
  211. package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
  212. package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
  213. package/Tests/Server/Utils/ClientIp.test.ts +438 -0
  214. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
  215. package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
  216. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
  217. package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
  218. package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
  219. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
  220. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
  221. package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
  222. package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
  223. package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
  224. package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
  225. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +15 -15
  226. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
  227. package/Tests/Types/IP/IP.test.ts +263 -0
  228. package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
  229. package/Tests/Types/IP/IPv6.test.ts +12 -1
  230. package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
  231. package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
  232. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
  233. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
  234. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
  235. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
  236. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
  237. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +6 -6
  238. package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
  239. package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
  240. package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
  241. package/Tests/Types/Workflow/TemplateSyntax.test.ts +112 -0
  242. package/Tests/Types/Workflow/Templates.test.ts +916 -101
  243. package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
  244. package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
  245. package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
  246. package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
  247. package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
  248. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
  249. package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
  250. package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
  251. package/Tests/UI/Components/Workflow/GraphLint.test.ts +72 -0
  252. package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
  253. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +242 -7
  254. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +15 -7
  255. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +242 -28
  256. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +59 -1
  257. package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
  258. package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
  259. package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
  260. package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
  261. package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
  262. package/Tests/UI/EsbuildConfig.test.ts +607 -0
  263. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  264. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
  265. package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
  266. package/Tests/UI/Utils/Platform.test.ts +147 -0
  267. package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
  268. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
  269. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +487 -0
  270. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
  271. package/Tests/__mocks__/bullmq.js +55 -0
  272. package/Types/AI/AIChatMessageStatus.ts +8 -1
  273. package/Types/AI/AIChatTypes.ts +12 -0
  274. package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
  275. package/Types/Exception/ExceptionCode.ts +2 -0
  276. package/Types/Exception/ServiceUnavailableException.ts +8 -0
  277. package/Types/Exception/TooManyRequestsException.ts +8 -0
  278. package/Types/IP/IP.ts +93 -47
  279. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +49 -3
  280. package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
  281. package/Types/OnCallDutyPolicy/Layer.ts +203 -149
  282. package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
  283. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
  284. package/Types/Permission.ts +193 -0
  285. package/Types/Telemetry/EntityRelationshipType.ts +1 -1
  286. package/Types/Telemetry/EntitySource.ts +1 -1
  287. package/Types/Telemetry/EntityType.ts +1 -1
  288. package/Types/Telemetry/EntityTypeGroups.ts +1 -1
  289. package/Types/Workflow/Components/BaseModel.ts +75 -29
  290. package/Types/Workflow/Components/Discord.ts +1 -0
  291. package/Types/Workflow/Components/Email.ts +12 -3
  292. package/Types/Workflow/Components/JavaScript.ts +7 -0
  293. package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
  294. package/Types/Workflow/Components/Slack.ts +1 -0
  295. package/Types/Workflow/Components/Telegram.ts +1 -0
  296. package/Types/Workflow/TemplateSyntax.ts +44 -0
  297. package/Types/Workflow/Templates.ts +2097 -45
  298. package/UI/Components/Calendar/Calendar.css +43 -0
  299. package/UI/Components/Calendar/Calendar.tsx +8 -0
  300. package/UI/Components/Card/Card.tsx +2 -2
  301. package/UI/Components/Checkbox/Checkbox.tsx +16 -0
  302. package/UI/Components/Dictionary/Dictionary.tsx +48 -9
  303. package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
  304. package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
  305. package/UI/Components/Input/Input.tsx +1 -0
  306. package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
  307. package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
  308. package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
  309. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
  310. package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
  311. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
  312. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
  313. package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
  314. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
  315. package/UI/Components/LogsViewer/types.ts +23 -0
  316. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
  317. package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
  318. package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
  319. package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
  320. package/UI/Components/Table/Table.tsx +49 -16
  321. package/UI/Components/Table/TableBody.tsx +53 -28
  322. package/UI/Components/Table/TableHeader.tsx +13 -0
  323. package/UI/Components/Table/TableRow.tsx +58 -26
  324. package/UI/Components/Workflow/ArgumentsForm.tsx +155 -9
  325. package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
  326. package/UI/Components/Workflow/ComponentSettingsModal.tsx +10 -1
  327. package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
  328. package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
  329. package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
  330. package/UI/Components/Workflow/GraphLint.ts +42 -5
  331. package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
  332. package/UI/Components/Workflow/ModelColumnEditor.tsx +154 -22
  333. package/UI/Components/Workflow/ModelSchema.ts +115 -33
  334. package/UI/Components/Workflow/RunStatusWatcher.ts +1 -1
  335. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  336. package/UI/Components/Workflow/UseRunWatch.ts +212 -0
  337. package/UI/Components/Workflow/VariableModal.tsx +6 -2
  338. package/UI/Components/Workflow/Workflow.tsx +49 -3
  339. package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
  340. package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
  341. package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
  342. package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
  343. package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
  344. package/UI/Utils/Platform.ts +149 -0
  345. package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
  346. package/UI/esbuild-config.js +22 -1
  347. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
  348. package/Utils/Monitor/NetworkTopologyUtil.ts +888 -164
  349. package/Utils/Telemetry/CrossSignalScope.ts +502 -0
  350. package/Utils/Telemetry/EntityKey.ts +5 -5
  351. package/Utils/Telemetry/EntityRelationship.ts +1 -1
  352. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
  353. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
  354. package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
  355. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
  356. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
  357. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
  358. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
  359. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  360. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
  361. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  362. package/build/dist/Models/DatabaseModels/Index.js +12 -4
  363. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  364. package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +167 -31
  365. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
  366. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
  367. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
  368. package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +27 -27
  369. package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
  370. package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
  371. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  372. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
  373. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
  374. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
  375. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
  376. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
  377. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
  378. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
  379. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
  380. package/build/dist/Models/DatabaseModels/Project.js +80 -0
  381. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  382. package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
  383. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  384. package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
  385. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  386. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
  387. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  388. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
  389. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  390. package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
  391. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  392. package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
  393. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  394. package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
  395. package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
  396. package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
  397. package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
  398. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
  399. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  400. package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
  401. package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
  402. package/build/dist/Server/API/AIChatAPI.js +237 -1
  403. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  404. package/build/dist/Server/API/DashboardAPI.js +165 -13
  405. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  406. package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
  407. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
  408. package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
  409. package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
  410. package/build/dist/Server/API/TelemetryAPI.js +129 -18
  411. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  412. package/build/dist/Server/EnvironmentConfig.js +45 -0
  413. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  414. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
  415. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  416. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
  417. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
  418. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
  419. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
  420. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
  421. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
  422. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
  423. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
  424. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
  425. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
  426. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
  427. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
  428. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
  429. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
  430. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
  431. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
  432. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
  433. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
  434. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
  435. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
  436. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
  437. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +20 -0
  438. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  439. package/build/dist/Server/Infrastructure/Queue.js +72 -13
  440. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  441. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
  442. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
  443. package/build/dist/Server/Services/AIService.js +1 -0
  444. package/build/dist/Server/Services/AIService.js.map +1 -1
  445. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
  446. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
  447. package/build/dist/Server/Services/AlertSeverityService.js +54 -0
  448. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  449. package/build/dist/Server/Services/DashboardService.js +10 -9
  450. package/build/dist/Server/Services/DashboardService.js.map +1 -1
  451. package/build/dist/Server/Services/DatabaseService.js +24 -2
  452. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  453. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
  454. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  455. package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
  456. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  457. package/build/dist/Server/Services/Index.js +12 -4
  458. package/build/dist/Server/Services/Index.js.map +1 -1
  459. package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
  460. package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
  461. package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +6 -6
  462. package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
  463. package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +68 -23
  464. package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
  465. package/build/dist/Server/Services/LogAggregationService.js +27 -8
  466. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  467. package/build/dist/Server/Services/MetricAggregationService.js +80 -0
  468. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  469. package/build/dist/Server/Services/MetricService.js +6 -6
  470. package/build/dist/Server/Services/MetricService.js.map +1 -1
  471. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
  472. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
  473. package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
  474. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
  475. package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
  476. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  477. package/build/dist/Server/Services/NetworkSiteService.js +53 -13
  478. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  479. package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
  480. package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
  481. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
  482. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  483. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
  484. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  485. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
  486. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
  487. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
  488. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  489. package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
  490. package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
  491. package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
  492. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
  493. package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
  494. package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
  495. package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
  496. package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
  497. package/build/dist/Server/Services/StatusPageService.js +11 -10
  498. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  499. package/build/dist/Server/Services/TeamComplianceService.js +312 -160
  500. package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
  501. package/build/dist/Server/Services/UserCallService.js +24 -1
  502. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  503. package/build/dist/Server/Services/UserEmailService.js +24 -1
  504. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  505. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
  506. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
  507. package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
  508. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  509. package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
  510. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  511. package/build/dist/Server/Services/UserPushService.js +26 -0
  512. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  513. package/build/dist/Server/Services/UserService.js +10 -0
  514. package/build/dist/Server/Services/UserService.js.map +1 -1
  515. package/build/dist/Server/Services/UserSmsService.js +24 -1
  516. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  517. package/build/dist/Server/Services/UserTelegramService.js +22 -1
  518. package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
  519. package/build/dist/Server/Services/UserWebhookService.js +25 -1
  520. package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
  521. package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
  522. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  523. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
  524. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  525. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
  526. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
  527. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
  528. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
  529. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
  530. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
  531. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
  532. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  533. package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
  534. package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
  535. package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
  536. package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
  537. package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
  538. package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
  539. package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
  540. package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
  541. package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
  542. package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
  543. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
  544. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
  545. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
  546. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
  547. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
  548. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
  549. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
  550. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  551. package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
  552. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  553. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
  554. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  555. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
  556. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
  557. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
  558. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  559. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
  560. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
  561. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
  562. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
  563. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
  564. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  565. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
  566. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
  567. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
  568. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
  569. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
  570. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
  571. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
  572. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  573. package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
  574. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  575. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
  576. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
  577. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
  578. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
  579. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
  580. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
  581. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
  582. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
  583. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
  584. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
  585. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
  586. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
  587. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
  588. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
  589. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
  590. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
  591. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
  592. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
  593. package/build/dist/Server/Utils/ClientIp.js +137 -0
  594. package/build/dist/Server/Utils/ClientIp.js.map +1 -0
  595. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
  596. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  597. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
  598. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
  599. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
  600. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
  601. package/build/dist/Server/Utils/Express.js +12 -12
  602. package/build/dist/Server/Utils/Express.js.map +1 -1
  603. package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
  604. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  605. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
  606. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  607. package/build/dist/Server/Utils/SSRFProtection.js +82 -21
  608. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  609. package/build/dist/Server/Utils/StartServer.js +12 -4
  610. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  611. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
  612. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  613. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +11 -11
  614. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  615. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
  616. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  617. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
  618. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  619. package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
  620. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
  621. package/build/dist/Types/AI/AIChatTypes.js +12 -0
  622. package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
  623. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
  624. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
  625. package/build/dist/Types/Exception/ExceptionCode.js +2 -0
  626. package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
  627. package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
  628. package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
  629. package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
  630. package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
  631. package/build/dist/Types/IP/IP.js +87 -43
  632. package/build/dist/Types/IP/IP.js.map +1 -1
  633. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
  634. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
  635. package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
  636. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  637. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
  638. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
  639. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
  640. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  641. package/build/dist/Types/Permission.js +174 -0
  642. package/build/dist/Types/Permission.js.map +1 -1
  643. package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
  644. package/build/dist/Types/Telemetry/EntitySource.js +1 -1
  645. package/build/dist/Types/Telemetry/EntityType.js +1 -1
  646. package/build/dist/Types/Telemetry/EntityTypeGroups.js +1 -1
  647. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -1
  648. package/build/dist/Types/Workflow/Components/BaseModel.js +66 -28
  649. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  650. package/build/dist/Types/Workflow/Components/Discord.js +1 -0
  651. package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
  652. package/build/dist/Types/Workflow/Components/Email.js +12 -3
  653. package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
  654. package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
  655. package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
  656. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
  657. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
  658. package/build/dist/Types/Workflow/Components/Slack.js +1 -0
  659. package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
  660. package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
  661. package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
  662. package/build/dist/Types/Workflow/TemplateSyntax.js +12 -0
  663. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -1
  664. package/build/dist/Types/Workflow/Templates.js +1931 -38
  665. package/build/dist/Types/Workflow/Templates.js.map +1 -1
  666. package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
  667. package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
  668. package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
  669. package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
  670. package/build/dist/UI/Components/Dictionary/Dictionary.js +25 -11
  671. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  672. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  673. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
  674. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  675. package/build/dist/UI/Components/Input/Input.js +1 -0
  676. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  677. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
  678. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
  679. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
  680. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
  681. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
  682. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  683. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
  684. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
  685. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
  686. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
  687. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
  688. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  689. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
  690. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  691. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
  692. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  693. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
  694. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  695. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  696. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
  697. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  698. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
  699. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  700. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
  701. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
  702. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
  703. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
  704. package/build/dist/UI/Components/Table/Table.js +27 -15
  705. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  706. package/build/dist/UI/Components/Table/TableBody.js +24 -18
  707. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  708. package/build/dist/UI/Components/Table/TableHeader.js +9 -1
  709. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  710. package/build/dist/UI/Components/Table/TableRow.js +29 -21
  711. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  712. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +130 -12
  713. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  714. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
  715. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
  716. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +4 -4
  717. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  718. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
  719. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
  720. package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
  721. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  722. package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
  723. package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
  724. package/build/dist/UI/Components/Workflow/GraphLint.js +33 -4
  725. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -1
  726. package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
  727. package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
  728. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +116 -21
  729. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  730. package/build/dist/UI/Components/Workflow/ModelSchema.js +72 -34
  731. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -1
  732. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +1 -1
  733. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -1
  734. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  735. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  736. package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
  737. package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
  738. package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
  739. package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
  740. package/build/dist/UI/Components/Workflow/Workflow.js +41 -1
  741. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  742. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
  743. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
  744. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
  745. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
  746. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
  747. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
  748. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
  749. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
  750. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
  751. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  752. package/build/dist/UI/Utils/Platform.js +118 -0
  753. package/build/dist/UI/Utils/Platform.js.map +1 -0
  754. package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
  755. package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
  756. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
  757. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
  758. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +642 -136
  759. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  760. package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
  761. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
  762. package/build/dist/Utils/Telemetry/EntityKey.js +5 -5
  763. package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
  764. package/jest.config.json +1 -0
  765. package/package.json +1 -1
  766. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
  767. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
  768. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
  769. package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
@@ -0,0 +1,4295 @@
1
+ import AlertSeverityService from "../../../Server/Services/AlertSeverityService";
2
+ import IncidentSeverityService from "../../../Server/Services/IncidentSeverityService";
3
+ import OnCallDutyPolicyEscalationRuleScheduleService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleScheduleService";
4
+ import OnCallDutyPolicyEscalationRuleTeamService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleTeamService";
5
+ import OnCallDutyPolicyEscalationRuleUserService from "../../../Server/Services/OnCallDutyPolicyEscalationRuleUserService";
6
+ import OnCallDutyPolicyScheduleLayerUserService from "../../../Server/Services/OnCallDutyPolicyScheduleLayerUserService";
7
+ import OnCallDutyPolicyService from "../../../Server/Services/OnCallDutyPolicyService";
8
+ import OnCallDutyPolicyUserOverrideService from "../../../Server/Services/OnCallDutyPolicyUserOverrideService";
9
+ import OnCallReadinessService, {
10
+ IDENTIFIER_MASK,
11
+ MaskedIdentifierKind,
12
+ ReadinessCoverageCell,
13
+ ReadinessMethod,
14
+ ReadinessMethodType,
15
+ ReadinessStatus,
16
+ ReadinessSummary,
17
+ ReadinessTeam,
18
+ ResponderSource,
19
+ UserReadiness,
20
+ maskIdentifier,
21
+ } from "../../../Server/Services/OnCallReadinessService";
22
+ import ProjectService from "../../../Server/Services/ProjectService";
23
+ import TeamMemberService from "../../../Server/Services/TeamMemberService";
24
+ import TeamService from "../../../Server/Services/TeamService";
25
+ import UserCallService from "../../../Server/Services/UserCallService";
26
+ import UserEmailService from "../../../Server/Services/UserEmailService";
27
+ import UserNotificationRuleService from "../../../Server/Services/UserNotificationRuleService";
28
+ import UserPushService from "../../../Server/Services/UserPushService";
29
+ import UserService from "../../../Server/Services/UserService";
30
+ import UserSmsService from "../../../Server/Services/UserSmsService";
31
+ import UserTelegramService from "../../../Server/Services/UserTelegramService";
32
+ import UserWebhookService from "../../../Server/Services/UserWebhookService";
33
+ import UserWhatsAppService from "../../../Server/Services/UserWhatsAppService";
34
+ import logger from "../../../Server/Utils/Logger";
35
+ import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
36
+ import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
37
+ import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
38
+ import OnCallDutyPolicyEscalationRuleSchedule from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleSchedule";
39
+ import OnCallDutyPolicyEscalationRuleTeam from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleTeam";
40
+ import OnCallDutyPolicyEscalationRuleUser from "../../../Models/DatabaseModels/OnCallDutyPolicyEscalationRuleUser";
41
+ import OnCallDutyPolicyScheduleLayerUser from "../../../Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser";
42
+ import OnCallDutyPolicyUserOverride from "../../../Models/DatabaseModels/OnCallDutyPolicyUserOverride";
43
+ import Project from "../../../Models/DatabaseModels/Project";
44
+ import Team from "../../../Models/DatabaseModels/Team";
45
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
46
+ import User from "../../../Models/DatabaseModels/User";
47
+ import UserCall from "../../../Models/DatabaseModels/UserCall";
48
+ import UserEmail from "../../../Models/DatabaseModels/UserEmail";
49
+ import UserNotificationRule from "../../../Models/DatabaseModels/UserNotificationRule";
50
+ import UserPush from "../../../Models/DatabaseModels/UserPush";
51
+ import UserSMS from "../../../Models/DatabaseModels/UserSMS";
52
+ import UserTelegram from "../../../Models/DatabaseModels/UserTelegram";
53
+ import UserWebhook from "../../../Models/DatabaseModels/UserWebhook";
54
+ import UserWhatsApp from "../../../Models/DatabaseModels/UserWhatsApp";
55
+ import Includes from "../../../Types/BaseDatabase/Includes";
56
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
57
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
58
+ import Email from "../../../Types/Email";
59
+ import Name from "../../../Types/Name";
60
+ import NotificationRuleType from "../../../Types/NotificationRule/NotificationRuleType";
61
+ import ObjectID from "../../../Types/ObjectID";
62
+ import Phone from "../../../Types/Phone";
63
+ import { FindOperator } from "typeorm";
64
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
65
+
66
+ /*
67
+ * OnCallReadinessService answers exactly one question - "can this responder
68
+ * actually be paged?" - and every readiness surface in the product renders what
69
+ * it returns. That makes each of its judgements load-bearing in the most
70
+ * literal sense: a false green is a page that lands nowhere while an admin
71
+ * looks at a table telling them everything is fine.
72
+ *
73
+ * It replaces TeamComplianceService, which answered the same question wrongly
74
+ * in seven separate ways. Six of those seven are structural properties of this
75
+ * service rather than behaviours you would stumble across, so they are pinned
76
+ * here deliberately and each test says which defect it is standing guard over:
77
+ *
78
+ * - team-scoping (a direct/schedule/override responder was never checked)
79
+ * - a bare `limit: 100` that silently truncated a real project
80
+ * - matching on severity alone, so a "when I go off call" rule certified
81
+ * incident coverage
82
+ * - counting only four of the seven channels, so a Telegram-only responder
83
+ * was reported unreachable while the runtime paged them happily
84
+ * - one findBy per severity per user
85
+ * - being opt-in and off by default
86
+ *
87
+ * Three further properties are pinned because getting them wrong is silent and
88
+ * expensive rather than merely wrong:
89
+ *
90
+ * MASKING. Every identifier this service emits is redacted server-side, and
91
+ * the tests assert on the SERIALIZED summary rather than on the fields a test
92
+ * author happened to remember - a leak that arrives through a field nobody
93
+ * listed is exactly the leak that ships.
94
+ *
95
+ * THE OPT-OUT PREDICATE. `isOptOut` is nullable and was added long after
96
+ * these rows started existing, so it is NULL on every pre-existing rule. The
97
+ * naive `isOptOut === false` split would classify every one of them as
98
+ * neither a rule nor an opt-out and report a fully-configured project as
99
+ * entirely unready. There is a test whose whole job is to fail if anyone
100
+ * writes that.
101
+ *
102
+ * THE SEVERITY MODEL PER RULE TYPE. Incident and incident-episode rules are
103
+ * scoped by IncidentSeverity, alert and alert-episode by AlertSeverity.
104
+ * Crossing them produces cells that can never match anything, which is the
105
+ * exact shape of Gap G - rules that were invisible and unreachable at the
106
+ * same time.
107
+ *
108
+ * Nothing here touches a database. Every read is a jest.spyOn at the service
109
+ * boundary, and the fixtures are ordinary models, so what is under test is this
110
+ * service's own decision-making.
111
+ */
112
+
113
+ const PROJECT_ID: ObjectID = new ObjectID(
114
+ "11111111-1111-4111-8111-111111111111",
115
+ );
116
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
117
+ "1e1e1e1e-1e1e-4e1e-8e1e-1e1e1e1e1e1e",
118
+ );
119
+ const POLICY_ID: ObjectID = new ObjectID(
120
+ "22222222-2222-4222-8222-222222222222",
121
+ );
122
+ const OTHER_POLICY_ID: ObjectID = new ObjectID(
123
+ "2f2f2f2f-2f2f-4f2f-8f2f-2f2f2f2f2f2f",
124
+ );
125
+
126
+ const USER_A_ID: ObjectID = new ObjectID(
127
+ "33333333-3333-4333-8333-333333333333",
128
+ );
129
+ const USER_B_ID: ObjectID = new ObjectID(
130
+ "44444444-4444-4444-8444-444444444444",
131
+ );
132
+ const USER_C_ID: ObjectID = new ObjectID(
133
+ "55555555-5555-4555-8555-555555555555",
134
+ );
135
+
136
+ const TEAM_ID: ObjectID = new ObjectID("66666666-6666-4666-8666-666666666666");
137
+ const OTHER_TEAM_ID: ObjectID = new ObjectID(
138
+ "6a6a6a6a-6a6a-4a6a-8a6a-6a6a6a6a6a6a",
139
+ );
140
+ const SCHEDULE_ID: ObjectID = new ObjectID(
141
+ "77777777-7777-4777-8777-777777777777",
142
+ );
143
+
144
+ const INCIDENT_SEVERITY_1_ID: ObjectID = new ObjectID(
145
+ "88888888-8888-4888-8888-888888888888",
146
+ );
147
+ const INCIDENT_SEVERITY_2_ID: ObjectID = new ObjectID(
148
+ "99999999-9999-4999-8999-999999999999",
149
+ );
150
+ const ALERT_SEVERITY_1_ID: ObjectID = new ObjectID(
151
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
152
+ );
153
+ const ALERT_SEVERITY_2_ID: ObjectID = new ObjectID(
154
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
155
+ );
156
+
157
+ /*
158
+ * One id per channel for the METHOD ROWS themselves - UserSMS._id, UserEmail._id and so
159
+ * on - which are what UserNotificationRule.userSmsId / userEmailId / ... reference and
160
+ * what the admin rule form has to submit.
161
+ *
162
+ * They are all distinct from each other AND from every user id above, deliberately: the
163
+ * mistake this pins against is wiring `row.userId` into the payload instead of `row.id`,
164
+ * which type-checks perfectly, renders perfectly, and produces a rule pointed at a row
165
+ * that is not a notification method at all.
166
+ */
167
+ const PUSH_METHOD_ID: ObjectID = new ObjectID(
168
+ "c0000000-0000-4000-8000-000000000001",
169
+ );
170
+ const EMAIL_METHOD_ID: ObjectID = new ObjectID(
171
+ "c0000000-0000-4000-8000-000000000002",
172
+ );
173
+ const SMS_METHOD_ID: ObjectID = new ObjectID(
174
+ "c0000000-0000-4000-8000-000000000003",
175
+ );
176
+ const CALL_METHOD_ID: ObjectID = new ObjectID(
177
+ "c0000000-0000-4000-8000-000000000004",
178
+ );
179
+ const WHATSAPP_METHOD_ID: ObjectID = new ObjectID(
180
+ "c0000000-0000-4000-8000-000000000005",
181
+ );
182
+ const TELEGRAM_METHOD_ID: ObjectID = new ObjectID(
183
+ "c0000000-0000-4000-8000-000000000006",
184
+ );
185
+ const WEBHOOK_METHOD_ID: ObjectID = new ObjectID(
186
+ "c0000000-0000-4000-8000-000000000007",
187
+ );
188
+
189
+ /*
190
+ * Raw identifiers that must never reach a caller. Each is deliberately
191
+ * distinctive so a substring search over the serialized summary cannot pass by
192
+ * accident, and each has a "revealing middle" that survives no legitimate mask.
193
+ */
194
+ const RAW_NOTIFICATION_EMAIL: string = "ada.lovelace@analytical-engine.example";
195
+ const RAW_SMS_PHONE: string = "+14155554821";
196
+ const RAW_CALL_PHONE: string = "+442071838750";
197
+ const RAW_WHATSAPP_PHONE: string = "+61293744000";
198
+ const RAW_TELEGRAM_HANDLE: string = "@ada_night_pager";
199
+ const RAW_WEBHOOK_NAME: string = "Payments Incident Bridge";
200
+ const RAW_WEBHOOK_URL: string =
201
+ "https://hooks.example.com/T0LEAK/B0LEAK/xoxbSUPERSECRETTOKEN";
202
+ const RAW_PUSH_DEVICE: string = "Ada iPhone 15 Pro Max";
203
+
204
+ /** The login email, which the frozen contract says is deliberately NOT masked. */
205
+ const USER_A_LOGIN_EMAIL: string = "ada@corp.example.com";
206
+
207
+ /*
208
+ * Enough of a findBy argument to assert on without importing the FindBy
209
+ * generics. `query` is deliberately a Record so that assertions have to use
210
+ * bracket access and cannot accidentally rely on a typed field that the
211
+ * production code never set.
212
+ */
213
+ interface FindByCall {
214
+ query: Record<string, unknown>;
215
+ select?: Record<string, unknown> | undefined;
216
+ sort?: Record<string, unknown> | undefined;
217
+ limit?: number | undefined;
218
+ skip?: number | undefined;
219
+ props?: { isRoot?: boolean } | undefined;
220
+ }
221
+
222
+ interface UserFindByArgument {
223
+ query: { _id?: Includes | undefined };
224
+ }
225
+
226
+ interface TeamMemberFindByArgument {
227
+ query: { teamId?: Includes | undefined; userId?: Includes | undefined };
228
+ }
229
+
230
+ interface TeamFindByArgument {
231
+ query: { _id?: Includes | undefined };
232
+ }
233
+
234
+ /*
235
+ * The mutable world every spy reads from. Reset wholesale in beforeEach so no
236
+ * test can inherit another's configuration - readiness is a whole-project
237
+ * roll-up and a leaked responder would change a count somewhere far away.
238
+ */
239
+ let userDirectory: Array<User> = [];
240
+ let membershipRows: Array<TeamMember> = [];
241
+ let teamMemberRows: Array<TeamMember> = [];
242
+ /*
243
+ * Every team that exists, by id. The service reads names for the teams that page
244
+ * somebody; a team missing from here is a team whose row did not come back,
245
+ * which is a state the contract has an opinion about.
246
+ */
247
+ let teamDirectory: Array<Team> = [];
248
+
249
+ let policyFindOneById: jest.SpyInstance;
250
+ let escalationUserFindBy: jest.SpyInstance;
251
+ let escalationTeamFindBy: jest.SpyInstance;
252
+ let escalationScheduleFindBy: jest.SpyInstance;
253
+ let scheduleLayerUserFindBy: jest.SpyInstance;
254
+ let overrideFindBy: jest.SpyInstance;
255
+ let teamMemberFindBy: jest.SpyInstance;
256
+ let teamFindBy: jest.SpyInstance;
257
+ let userFindBy: jest.SpyInstance;
258
+ let pushFindBy: jest.SpyInstance;
259
+ let emailFindBy: jest.SpyInstance;
260
+ let smsFindBy: jest.SpyInstance;
261
+ let callFindBy: jest.SpyInstance;
262
+ let whatsAppFindBy: jest.SpyInstance;
263
+ let telegramFindBy: jest.SpyInstance;
264
+ let webhookFindBy: jest.SpyInstance;
265
+ let notificationRuleFindBy: jest.SpyInstance;
266
+ let incidentSeverityFindBy: jest.SpyInstance;
267
+ let alertSeverityFindBy: jest.SpyInstance;
268
+ let projectFindOneById: jest.SpyInstance;
269
+
270
+ function makeUser(id: ObjectID, name: string, loginEmail: string): User {
271
+ const user: User = new User();
272
+ user.id = id;
273
+ user.name = new Name(name);
274
+ user.email = new Email(loginEmail);
275
+
276
+ return user;
277
+ }
278
+
279
+ function makePolicy(projectId: ObjectID): OnCallDutyPolicy {
280
+ const policy: OnCallDutyPolicy = new OnCallDutyPolicy();
281
+ policy.id = POLICY_ID;
282
+ policy.projectId = projectId;
283
+
284
+ return policy;
285
+ }
286
+
287
+ function makeProject(
288
+ overrides: Partial<{
289
+ disableOnCallNotificationFallback: boolean;
290
+ enableSmsNotifications: boolean;
291
+ enableCallNotifications: boolean;
292
+ enableWhatsAppNotifications: boolean;
293
+ enableTelegramNotifications: boolean;
294
+ }> = {},
295
+ ): Project {
296
+ const project: Project = new Project();
297
+ project.id = PROJECT_ID;
298
+ project.disableOnCallNotificationFallback =
299
+ overrides.disableOnCallNotificationFallback === true;
300
+ project.enableSmsNotifications = overrides.enableSmsNotifications !== false;
301
+ project.enableCallNotifications = overrides.enableCallNotifications !== false;
302
+ project.enableWhatsAppNotifications =
303
+ overrides.enableWhatsAppNotifications !== false;
304
+ project.enableTelegramNotifications =
305
+ overrides.enableTelegramNotifications !== false;
306
+
307
+ return project;
308
+ }
309
+
310
+ function escalationUserRow(
311
+ userId: ObjectID | undefined,
312
+ ): OnCallDutyPolicyEscalationRuleUser {
313
+ const row: OnCallDutyPolicyEscalationRuleUser =
314
+ new OnCallDutyPolicyEscalationRuleUser();
315
+
316
+ if (userId) {
317
+ row.userId = userId;
318
+ }
319
+
320
+ return row;
321
+ }
322
+
323
+ function escalationTeamRow(
324
+ teamId: ObjectID,
325
+ ): OnCallDutyPolicyEscalationRuleTeam {
326
+ const row: OnCallDutyPolicyEscalationRuleTeam =
327
+ new OnCallDutyPolicyEscalationRuleTeam();
328
+ row.teamId = teamId;
329
+
330
+ return row;
331
+ }
332
+
333
+ function escalationScheduleRow(
334
+ scheduleId: ObjectID,
335
+ ): OnCallDutyPolicyEscalationRuleSchedule {
336
+ const row: OnCallDutyPolicyEscalationRuleSchedule =
337
+ new OnCallDutyPolicyEscalationRuleSchedule();
338
+ row.onCallDutyPolicyScheduleId = scheduleId;
339
+
340
+ return row;
341
+ }
342
+
343
+ function layerUserRow(
344
+ userId: ObjectID,
345
+ scheduleId?: ObjectID | undefined,
346
+ ): OnCallDutyPolicyScheduleLayerUser {
347
+ const row: OnCallDutyPolicyScheduleLayerUser =
348
+ new OnCallDutyPolicyScheduleLayerUser();
349
+ row.userId = userId;
350
+
351
+ /*
352
+ * The schedule id matters only on the user-scoped path, which asks "which
353
+ * schedules is this person on" and then "are any of those attached to a
354
+ * rule". The project-scoped path arrives from the other direction and reads
355
+ * only the userId.
356
+ */
357
+ if (scheduleId) {
358
+ row.onCallDutyPolicyScheduleId = scheduleId;
359
+ }
360
+
361
+ return row;
362
+ }
363
+
364
+ function overrideRow(
365
+ routeAlertsToUserId: ObjectID | undefined,
366
+ ): OnCallDutyPolicyUserOverride {
367
+ const row: OnCallDutyPolicyUserOverride = new OnCallDutyPolicyUserOverride();
368
+
369
+ if (routeAlertsToUserId) {
370
+ row.routeAlertsToUserId = routeAlertsToUserId;
371
+ }
372
+
373
+ return row;
374
+ }
375
+
376
+ /*
377
+ * A membership row carries its teamId as well as its userId, because the
378
+ * service reads both out of one query: membership IS the cross-project guard,
379
+ * and the teamIds that come back with it are how one user's Team source is
380
+ * resolved without expanding every team in the project into its full roster.
381
+ */
382
+ function teamMemberRow(
383
+ userId: ObjectID,
384
+ teamId?: ObjectID | undefined,
385
+ ): TeamMember {
386
+ const row: TeamMember = new TeamMember();
387
+ row.userId = userId;
388
+
389
+ if (teamId) {
390
+ row.teamId = teamId;
391
+ }
392
+
393
+ return row;
394
+ }
395
+
396
+ function teamRow(id: ObjectID, name: string): Team {
397
+ const row: Team = new Team();
398
+ row.id = id;
399
+ row.name = name;
400
+
401
+ return row;
402
+ }
403
+
404
+ function incidentSeverity(id: ObjectID, name: string): IncidentSeverity {
405
+ const severity: IncidentSeverity = new IncidentSeverity();
406
+ severity.id = id;
407
+ severity.name = name;
408
+
409
+ return severity;
410
+ }
411
+
412
+ function alertSeverity(id: ObjectID, name: string): AlertSeverity {
413
+ const severity: AlertSeverity = new AlertSeverity();
414
+ severity.id = id;
415
+ severity.name = name;
416
+
417
+ return severity;
418
+ }
419
+
420
+ /*
421
+ * Every method fixture below carries an id, defaulting to a fresh one, because every
422
+ * method ROW carries one: `_id` is the primary key and every select in the service asks
423
+ * for it. A fixture without an id is not a row that could exist, and the service drops
424
+ * such a row rather than emitting a method the rule form could not point at - so a
425
+ * default here keeps that deliberate drop from quietly deleting the methods out of every
426
+ * unrelated test. The tests that care about a specific id pass one of the constants above.
427
+ */
428
+ function pushMethod(data: {
429
+ userId: ObjectID;
430
+ deviceName?: string | undefined;
431
+ isVerified: boolean;
432
+ id?: ObjectID | undefined;
433
+ }): UserPush {
434
+ const model: UserPush = new UserPush();
435
+ model.id = data.id || ObjectID.generate();
436
+ model.userId = data.userId;
437
+ model.isVerified = data.isVerified;
438
+
439
+ if (data.deviceName !== undefined) {
440
+ model.deviceName = data.deviceName;
441
+ }
442
+
443
+ return model;
444
+ }
445
+
446
+ function emailMethod(data: {
447
+ userId: ObjectID;
448
+ email: string;
449
+ isVerified: boolean;
450
+ id?: ObjectID | undefined;
451
+ }): UserEmail {
452
+ const model: UserEmail = new UserEmail();
453
+ model.id = data.id || ObjectID.generate();
454
+ model.userId = data.userId;
455
+ model.email = new Email(data.email);
456
+ model.isVerified = data.isVerified;
457
+
458
+ return model;
459
+ }
460
+
461
+ function smsMethod(data: {
462
+ userId: ObjectID;
463
+ phone: string;
464
+ isVerified: boolean;
465
+ id?: ObjectID | undefined;
466
+ }): UserSMS {
467
+ const model: UserSMS = new UserSMS();
468
+ model.id = data.id || ObjectID.generate();
469
+ model.userId = data.userId;
470
+ model.phone = new Phone(data.phone);
471
+ model.isVerified = data.isVerified;
472
+
473
+ return model;
474
+ }
475
+
476
+ function callMethod(data: {
477
+ userId: ObjectID;
478
+ phone: string;
479
+ isVerified: boolean;
480
+ id?: ObjectID | undefined;
481
+ }): UserCall {
482
+ const model: UserCall = new UserCall();
483
+ model.id = data.id || ObjectID.generate();
484
+ model.userId = data.userId;
485
+ model.phone = new Phone(data.phone);
486
+ model.isVerified = data.isVerified;
487
+
488
+ return model;
489
+ }
490
+
491
+ function whatsAppMethod(data: {
492
+ userId: ObjectID;
493
+ phone: string;
494
+ isVerified: boolean;
495
+ id?: ObjectID | undefined;
496
+ }): UserWhatsApp {
497
+ const model: UserWhatsApp = new UserWhatsApp();
498
+ model.id = data.id || ObjectID.generate();
499
+ model.userId = data.userId;
500
+ model.phone = new Phone(data.phone);
501
+ model.isVerified = data.isVerified;
502
+
503
+ return model;
504
+ }
505
+
506
+ function telegramMethod(data: {
507
+ userId: ObjectID;
508
+ handle: string;
509
+ isVerified: boolean;
510
+ id?: ObjectID | undefined;
511
+ }): UserTelegram {
512
+ const model: UserTelegram = new UserTelegram();
513
+ model.id = data.id || ObjectID.generate();
514
+ model.userId = data.userId;
515
+ model.telegramUserHandle = data.handle;
516
+ model.isVerified = data.isVerified;
517
+
518
+ return model;
519
+ }
520
+
521
+ /*
522
+ * A webhook fixture that deliberately CARRIES its bearer url, even though the
523
+ * production select must never ask for it. If the select ever widens, the
524
+ * leak assertions downstream see a real credential rather than an undefined.
525
+ */
526
+ function webhookMethod(data: {
527
+ userId: ObjectID;
528
+ name: string;
529
+ webhookUrl?: string | undefined;
530
+ id?: ObjectID | undefined;
531
+ }): UserWebhook {
532
+ const model: UserWebhook = new UserWebhook();
533
+ model.id = data.id || ObjectID.generate();
534
+ model.userId = data.userId;
535
+ model.name = data.name;
536
+
537
+ if (data.webhookUrl !== undefined) {
538
+ model.webhookUrl = data.webhookUrl;
539
+ }
540
+
541
+ return model;
542
+ }
543
+
544
+ function notificationRule(data: {
545
+ userId: ObjectID;
546
+ ruleType: NotificationRuleType;
547
+ incidentSeverityId?: ObjectID | undefined;
548
+ alertSeverityId?: ObjectID | undefined;
549
+ isOptOut?: boolean | undefined;
550
+ }): UserNotificationRule {
551
+ const rule: UserNotificationRule = new UserNotificationRule();
552
+ rule.userId = data.userId;
553
+ rule.ruleType = data.ruleType;
554
+
555
+ if (data.incidentSeverityId) {
556
+ rule.incidentSeverityId = data.incidentSeverityId;
557
+ }
558
+
559
+ if (data.alertSeverityId) {
560
+ rule.alertSeverityId = data.alertSeverityId;
561
+ }
562
+
563
+ if (data.isOptOut !== undefined) {
564
+ rule.isOptOut = data.isOptOut;
565
+ }
566
+
567
+ return rule;
568
+ }
569
+
570
+ function firstCall(spy: jest.SpyInstance): FindByCall {
571
+ return spy.mock.calls[0]![0] as FindByCall;
572
+ }
573
+
574
+ function callAt(spy: jest.SpyInstance, index: number): FindByCall {
575
+ return spy.mock.calls[index]![0] as FindByCall;
576
+ }
577
+
578
+ function includedIds(includes: unknown): Array<string> {
579
+ const values: Array<string | ObjectID | number> = (includes as Includes)
580
+ .values as Array<string | ObjectID | number>;
581
+
582
+ return values.map((value: string | ObjectID | number): string => {
583
+ return value.toString();
584
+ });
585
+ }
586
+
587
+ /** Every read the service can make, so a test can count them as one number. */
588
+ function everySpy(): Array<jest.SpyInstance> {
589
+ return [
590
+ policyFindOneById,
591
+ escalationUserFindBy,
592
+ escalationTeamFindBy,
593
+ escalationScheduleFindBy,
594
+ scheduleLayerUserFindBy,
595
+ overrideFindBy,
596
+ teamMemberFindBy,
597
+ teamFindBy,
598
+ userFindBy,
599
+ pushFindBy,
600
+ emailFindBy,
601
+ smsFindBy,
602
+ callFindBy,
603
+ whatsAppFindBy,
604
+ telegramFindBy,
605
+ webhookFindBy,
606
+ notificationRuleFindBy,
607
+ incidentSeverityFindBy,
608
+ alertSeverityFindBy,
609
+ projectFindOneById,
610
+ ];
611
+ }
612
+
613
+ function totalQueryCount(): number {
614
+ return everySpy().reduce((sum: number, spy: jest.SpyInstance): number => {
615
+ return sum + spy.mock.calls.length;
616
+ }, 0);
617
+ }
618
+
619
+ /** Every findBy argument the service passed, for the whole-suite invariants. */
620
+ function everyFindByCall(): Array<FindByCall> {
621
+ const calls: Array<FindByCall> = [];
622
+
623
+ for (const spy of everySpy()) {
624
+ if (spy === policyFindOneById || spy === projectFindOneById) {
625
+ // findOneById takes an id, not a limit; nothing to assert about paging.
626
+ continue;
627
+ }
628
+
629
+ for (const call of spy.mock.calls) {
630
+ calls.push(call[0] as FindByCall);
631
+ }
632
+ }
633
+
634
+ return calls;
635
+ }
636
+
637
+ function setSeverities(data: {
638
+ incident?: Array<IncidentSeverity> | undefined;
639
+ alert?: Array<AlertSeverity> | undefined;
640
+ }): void {
641
+ incidentSeverityFindBy.mockResolvedValue((data.incident || []) as never);
642
+ alertSeverityFindBy.mockResolvedValue((data.alert || []) as never);
643
+ }
644
+
645
+ /** One direct responder on the policy - the simplest shape that pages anyone. */
646
+ function attachDirectly(...userIds: Array<ObjectID>): void {
647
+ escalationUserFindBy.mockResolvedValue(
648
+ userIds.map((userId: ObjectID): OnCallDutyPolicyEscalationRuleUser => {
649
+ return escalationUserRow(userId);
650
+ }) as never,
651
+ );
652
+ }
653
+
654
+ function policySummary(): Promise<ReadinessSummary> {
655
+ return OnCallReadinessService.getReadinessForPolicy(POLICY_ID, PROJECT_ID);
656
+ }
657
+
658
+ async function onlyUser(): Promise<UserReadiness> {
659
+ const summary: ReadinessSummary = await policySummary();
660
+
661
+ expect(summary.users).toHaveLength(1);
662
+
663
+ return summary.users[0]!;
664
+ }
665
+
666
+ function cellFor(
667
+ readiness: UserReadiness,
668
+ ruleType: NotificationRuleType,
669
+ severityId: ObjectID,
670
+ ): ReadinessCoverageCell {
671
+ const cell: ReadinessCoverageCell | undefined = readiness.coverage.find(
672
+ (candidate: ReadinessCoverageCell): boolean => {
673
+ return (
674
+ candidate.ruleType === ruleType &&
675
+ candidate.severityId?.toString() === severityId.toString()
676
+ );
677
+ },
678
+ );
679
+
680
+ if (!cell) {
681
+ throw new Error(
682
+ `No coverage cell for ${ruleType} / ${severityId.toString()}`,
683
+ );
684
+ }
685
+
686
+ return cell;
687
+ }
688
+
689
+ function methodTypes(readiness: UserReadiness): Array<string> {
690
+ return readiness.methods.map((method: ReadinessMethod): string => {
691
+ return method.methodType;
692
+ });
693
+ }
694
+
695
+ function methodOfType(
696
+ readiness: UserReadiness,
697
+ methodType: ReadinessMethodType,
698
+ ): ReadinessMethod {
699
+ const method: ReadinessMethod | undefined = readiness.methods.find(
700
+ (candidate: ReadinessMethod): boolean => {
701
+ return candidate.methodType === methodType;
702
+ },
703
+ );
704
+
705
+ if (!method) {
706
+ throw new Error(`No ${methodType} method on this responder`);
707
+ }
708
+
709
+ return method;
710
+ }
711
+
712
+ beforeEach(() => {
713
+ /*
714
+ * The caches are static and live for the whole module, so a summary computed
715
+ * by the previous test would be served to this one and every spy assertion
716
+ * would read zero calls. Clearing here rather than in afterEach means a test
717
+ * that deliberately leaves something cached cannot poison the next file
718
+ * either.
719
+ */
720
+ OnCallReadinessService.clearCache();
721
+
722
+ userDirectory = [
723
+ makeUser(USER_A_ID, "Ada Lovelace", USER_A_LOGIN_EMAIL),
724
+ makeUser(USER_B_ID, "Grace Hopper", "grace@corp.example.com"),
725
+ makeUser(USER_C_ID, "Katherine Johnson", "katherine@corp.example.com"),
726
+ ];
727
+ membershipRows = [
728
+ teamMemberRow(USER_A_ID, TEAM_ID),
729
+ teamMemberRow(USER_B_ID, TEAM_ID),
730
+ teamMemberRow(USER_C_ID, TEAM_ID),
731
+ ];
732
+ teamMemberRows = [];
733
+ teamDirectory = [
734
+ teamRow(TEAM_ID, "Platform"),
735
+ teamRow(OTHER_TEAM_ID, "Payments"),
736
+ ];
737
+
738
+ policyFindOneById = jest
739
+ .spyOn(OnCallDutyPolicyService, "findOneById")
740
+ .mockResolvedValue(makePolicy(PROJECT_ID) as never);
741
+
742
+ // Default posture: nothing is attached to anything. Each test opts in.
743
+ escalationUserFindBy = jest
744
+ .spyOn(OnCallDutyPolicyEscalationRuleUserService, "findBy")
745
+ .mockResolvedValue([] as never);
746
+ escalationTeamFindBy = jest
747
+ .spyOn(OnCallDutyPolicyEscalationRuleTeamService, "findBy")
748
+ .mockResolvedValue([] as never);
749
+ escalationScheduleFindBy = jest
750
+ .spyOn(OnCallDutyPolicyEscalationRuleScheduleService, "findBy")
751
+ .mockResolvedValue([] as never);
752
+ scheduleLayerUserFindBy = jest
753
+ .spyOn(OnCallDutyPolicyScheduleLayerUserService, "findBy")
754
+ .mockResolvedValue([] as never);
755
+ overrideFindBy = jest
756
+ .spyOn(OnCallDutyPolicyUserOverrideService, "findBy")
757
+ .mockResolvedValue([] as never);
758
+
759
+ /*
760
+ * TeamMemberService is asked two structurally different questions - "which of
761
+ * these users is in the project, and in which teams" (userId) and "who is in
762
+ * these teams" (teamId) - so the fake dispatches on the query rather than
763
+ * answering both the same way, which would make the membership guard
764
+ * untestable.
765
+ *
766
+ * The membership answer is FILTERED by the userIds asked for. That is what
767
+ * makes the guard meaningful: a fake that returned somebody else's row for
768
+ * every question would report any user id on earth as a member of the
769
+ * project.
770
+ */
771
+ teamMemberFindBy = jest
772
+ .spyOn(TeamMemberService, "findBy")
773
+ .mockImplementation((async (
774
+ data: TeamMemberFindByArgument,
775
+ ): Promise<Array<TeamMember>> => {
776
+ if (data.query.teamId) {
777
+ return teamMemberRows;
778
+ }
779
+
780
+ const wanted: Set<string> = new Set<string>(
781
+ includedIds(data.query.userId || new Includes([])),
782
+ );
783
+
784
+ return membershipRows.filter((row: TeamMember): boolean => {
785
+ return wanted.has(row.userId?.toString() || "");
786
+ });
787
+ }) as never);
788
+
789
+ /*
790
+ * Team names, answered out of a directory filtered by the ids asked for. The
791
+ * filter is what makes the "one read for the whole responder set" test mean
792
+ * something: a fake that returned every team regardless would still work while
793
+ * the service asked once per member.
794
+ */
795
+ teamFindBy = jest.spyOn(TeamService, "findBy").mockImplementation((async (
796
+ data: TeamFindByArgument,
797
+ ): Promise<Array<Team>> => {
798
+ const wanted: Set<string> = new Set<string>(
799
+ includedIds(data.query._id || new Includes([])),
800
+ );
801
+
802
+ return teamDirectory.filter((team: Team): boolean => {
803
+ return wanted.has(team.id?.toString() || "");
804
+ });
805
+ }) as never);
806
+
807
+ /*
808
+ * The user lookup answers out of a directory filtered by the Includes the
809
+ * service passes. That is what makes the batching tests meaningful: if the
810
+ * service ever asked per user, this fake would still work but the call count
811
+ * would move, which is precisely the regression being guarded.
812
+ */
813
+ userFindBy = jest.spyOn(UserService, "findBy").mockImplementation((async (
814
+ data: UserFindByArgument,
815
+ ): Promise<Array<User>> => {
816
+ const wanted: Set<string> = new Set<string>(
817
+ includedIds(data.query._id || new Includes([])),
818
+ );
819
+
820
+ return userDirectory.filter((user: User): boolean => {
821
+ return wanted.has(user.id?.toString() || "");
822
+ });
823
+ }) as never);
824
+
825
+ pushFindBy = jest
826
+ .spyOn(UserPushService, "findBy")
827
+ .mockResolvedValue([] as never);
828
+ emailFindBy = jest
829
+ .spyOn(UserEmailService, "findBy")
830
+ .mockResolvedValue([] as never);
831
+ smsFindBy = jest
832
+ .spyOn(UserSmsService, "findBy")
833
+ .mockResolvedValue([] as never);
834
+ callFindBy = jest
835
+ .spyOn(UserCallService, "findBy")
836
+ .mockResolvedValue([] as never);
837
+ whatsAppFindBy = jest
838
+ .spyOn(UserWhatsAppService, "findBy")
839
+ .mockResolvedValue([] as never);
840
+ telegramFindBy = jest
841
+ .spyOn(UserTelegramService, "findBy")
842
+ .mockResolvedValue([] as never);
843
+ webhookFindBy = jest
844
+ .spyOn(UserWebhookService, "findBy")
845
+ .mockResolvedValue([] as never);
846
+
847
+ notificationRuleFindBy = jest
848
+ .spyOn(UserNotificationRuleService, "findBy")
849
+ .mockResolvedValue([] as never);
850
+
851
+ incidentSeverityFindBy = jest
852
+ .spyOn(IncidentSeverityService, "findBy")
853
+ .mockResolvedValue([] as never);
854
+ alertSeverityFindBy = jest
855
+ .spyOn(AlertSeverityService, "findBy")
856
+ .mockResolvedValue([] as never);
857
+
858
+ projectFindOneById = jest
859
+ .spyOn(ProjectService, "findOneById")
860
+ .mockResolvedValue(makeProject() as never);
861
+ });
862
+
863
+ afterEach(() => {
864
+ jest.restoreAllMocks();
865
+ OnCallReadinessService.clearCache();
866
+ });
867
+
868
+ /*
869
+ * ---------------------------------------------------------------------------
870
+ * (A) maskIdentifier as a pure function.
871
+ *
872
+ * Masking is the one rule in this service that must never be got wrong even
873
+ * slightly, and it is exported as a free function precisely so it can be
874
+ * exercised at this granularity. The interesting cases are all the DEGENERATE
875
+ * ones - a one-character handle, a four-digit number, a string with no "@" that
876
+ * arrived in an email column - because those are where a mask stops hiding
877
+ * anything and nobody notices.
878
+ * ---------------------------------------------------------------------------
879
+ */
880
+ describe("maskIdentifier", () => {
881
+ test("the shared mask constant is three bullets, so a look-alike typo is a test failure", () => {
882
+ expect(IDENTIFIER_MASK).toBe("•••");
883
+ expect(IDENTIFIER_MASK).toHaveLength(3);
884
+ });
885
+
886
+ describe("email", () => {
887
+ test("keeps one leading character and the whole domain", () => {
888
+ expect(
889
+ maskIdentifier("jane@example.com", MaskedIdentifierKind.Email),
890
+ ).toBe(`j${IDENTIFIER_MASK}@example.com`);
891
+ });
892
+
893
+ test("a plus-addressed local part is hidden past its first character", () => {
894
+ const masked: string = maskIdentifier(
895
+ "ops+pager-escalation@example.com",
896
+ MaskedIdentifierKind.Email,
897
+ );
898
+
899
+ expect(masked).toBe(`o${IDENTIFIER_MASK}@example.com`);
900
+ expect(masked).not.toContain("pager");
901
+ });
902
+
903
+ test("a single-character local part reveals nothing extra", () => {
904
+ expect(maskIdentifier("a@b.co", MaskedIdentifierKind.Email)).toBe(
905
+ `a${IDENTIFIER_MASK}@b.co`,
906
+ );
907
+ });
908
+
909
+ test("splits on the LAST @, so a quoted local part cannot smuggle the address through", () => {
910
+ expect(
911
+ maskIdentifier('"weird@local"@example.com', MaskedIdentifierKind.Email),
912
+ ).toBe(`"${IDENTIFIER_MASK}@example.com`);
913
+ });
914
+
915
+ test("a value with no @ falls through to the stricter handle rule rather than being guessed at", () => {
916
+ expect(maskIdentifier("notanemail", MaskedIdentifierKind.Email)).toBe(
917
+ `no${IDENTIFIER_MASK}`,
918
+ );
919
+ });
920
+
921
+ test("a leading @ with no local part is a handle, not a domain to publish", () => {
922
+ expect(maskIdentifier("@example.com", MaskedIdentifierKind.Email)).toBe(
923
+ `@ex${IDENTIFIER_MASK}`,
924
+ );
925
+ });
926
+ });
927
+
928
+ describe("phone", () => {
929
+ test("keeps the country code and the last four digits", () => {
930
+ expect(maskIdentifier("+14155554821", MaskedIdentifierKind.Phone)).toBe(
931
+ `+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`,
932
+ );
933
+ });
934
+
935
+ test("a two-digit country code survives, because the rule is 'everything before the last ten'", () => {
936
+ expect(maskIdentifier("+442071838750", MaskedIdentifierKind.Phone)).toBe(
937
+ `+44 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 8750`,
938
+ );
939
+ });
940
+
941
+ test("formatting characters are stripped before the digits are counted", () => {
942
+ expect(
943
+ maskIdentifier("+1 (415) 555-4821", MaskedIdentifierKind.Phone),
944
+ ).toBe(`+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
945
+ });
946
+
947
+ test("a country whose national plan is not ten digits loses a country-code digit, and hides MORE as a result", () => {
948
+ /*
949
+ * +61 2 9374 4000 is eleven digits, and the rule is "everything before
950
+ * the last ten is the country code", so the split lands after "6" rather
951
+ * than after "61". That is the heuristic behaving as documented rather
952
+ * than a defect: it is a parse-free rule, and the digit it gets wrong is
953
+ * one it HIDES. Correctness here is measured in what is concealed, and
954
+ * concealing an extra digit of a country code errs in the safe
955
+ * direction - so this is pinned deliberately, to keep anyone from
956
+ * "fixing" it into a real libphonenumber parse that could round the other
957
+ * way.
958
+ */
959
+ const masked: string = maskIdentifier(
960
+ "+61293744000",
961
+ MaskedIdentifierKind.Phone,
962
+ );
963
+
964
+ expect(masked).toBe(`+6 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4000`);
965
+ expect(masked).not.toContain("9374");
966
+ });
967
+
968
+ test("a bare national number produces no country code prefix", () => {
969
+ const masked: string = maskIdentifier(
970
+ "4155554821",
971
+ MaskedIdentifierKind.Phone,
972
+ );
973
+
974
+ expect(masked).toBe(`${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
975
+ expect(masked.startsWith("+")).toBe(false);
976
+ });
977
+
978
+ test("fewer than four digits is masked entirely - four digits of a three digit number is the number", () => {
979
+ expect(maskIdentifier("123", MaskedIdentifierKind.Phone)).toBe(
980
+ IDENTIFIER_MASK,
981
+ );
982
+ expect(maskIdentifier("+1", MaskedIdentifierKind.Phone)).toBe(
983
+ IDENTIFIER_MASK,
984
+ );
985
+ });
986
+
987
+ test("EXACTLY four digits is masked entirely - the last four of a four-digit value is the value", () => {
988
+ /*
989
+ * The boundary, and the one the original `< 4` got wrong. A four-digit
990
+ * value passed the guard and then had its "last four" revealed, which is
991
+ * all of it: the function returned the identifier in full while looking,
992
+ * on screen and in review, exactly like a mask. Anything at or below the
993
+ * number of digits the mask keeps has to be hidden completely, because a
994
+ * value we cannot hide half of is a value we do not show.
995
+ */
996
+ expect(maskIdentifier("4821", MaskedIdentifierKind.Phone)).toBe(
997
+ IDENTIFIER_MASK,
998
+ );
999
+ // Formatting is stripped before the digits are counted, so this is four.
1000
+ expect(maskIdentifier("(48) 21", MaskedIdentifierKind.Phone)).toBe(
1001
+ IDENTIFIER_MASK,
1002
+ );
1003
+ expect(maskIdentifier("0000", MaskedIdentifierKind.Phone)).toBe(
1004
+ IDENTIFIER_MASK,
1005
+ );
1006
+ });
1007
+
1008
+ test("five digits is the shortest value that reveals anything, and reveals only four of them", () => {
1009
+ const masked: string = maskIdentifier(
1010
+ "54821",
1011
+ MaskedIdentifierKind.Phone,
1012
+ );
1013
+
1014
+ expect(masked).toBe(`${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`);
1015
+ expect(masked.startsWith("5")).toBe(false);
1016
+ });
1017
+
1018
+ test("a value with no digits at all is masked entirely", () => {
1019
+ expect(maskIdentifier("not-a-phone", MaskedIdentifierKind.Phone)).toBe(
1020
+ IDENTIFIER_MASK,
1021
+ );
1022
+ });
1023
+ });
1024
+
1025
+ describe("handle", () => {
1026
+ test("keeps the @ sigil and two characters", () => {
1027
+ expect(maskIdentifier("@jamesbond", MaskedIdentifierKind.Handle)).toBe(
1028
+ `@ja${IDENTIFIER_MASK}`,
1029
+ );
1030
+ });
1031
+
1032
+ test("a handle without a sigil does not grow one", () => {
1033
+ expect(maskIdentifier("jamesbond", MaskedIdentifierKind.Handle)).toBe(
1034
+ `ja${IDENTIFIER_MASK}`,
1035
+ );
1036
+ });
1037
+
1038
+ test("a one-character handle reveals one character, not a padded lie", () => {
1039
+ expect(maskIdentifier("j", MaskedIdentifierKind.Handle)).toBe(
1040
+ `j${IDENTIFIER_MASK}`,
1041
+ );
1042
+ expect(maskIdentifier("@j", MaskedIdentifierKind.Handle)).toBe(
1043
+ `@j${IDENTIFIER_MASK}`,
1044
+ );
1045
+ });
1046
+
1047
+ test("a device name is trimmed before it is cut, so leading space is not spent as a revealed character", () => {
1048
+ expect(
1049
+ maskIdentifier(" Ada iPhone ", MaskedIdentifierKind.Handle),
1050
+ ).toBe(`Ad${IDENTIFIER_MASK}`);
1051
+ });
1052
+
1053
+ test("a long device name keeps nothing but its first two characters", () => {
1054
+ const masked: string = maskIdentifier(
1055
+ RAW_PUSH_DEVICE,
1056
+ MaskedIdentifierKind.Handle,
1057
+ );
1058
+
1059
+ expect(masked).toBe(`Ad${IDENTIFIER_MASK}`);
1060
+ expect(masked).not.toContain("iPhone");
1061
+ });
1062
+ });
1063
+
1064
+ describe("absent values", () => {
1065
+ test.each([
1066
+ ["undefined", undefined],
1067
+ ["null", null],
1068
+ ["empty", ""],
1069
+ ["whitespace", " "],
1070
+ ])(
1071
+ "%s masks to the bare bullet rather than a blank cell that reads as 'no method'",
1072
+ (_label: string, value: string | undefined | null) => {
1073
+ expect(maskIdentifier(value, MaskedIdentifierKind.Email)).toBe(
1074
+ IDENTIFIER_MASK,
1075
+ );
1076
+ expect(maskIdentifier(value, MaskedIdentifierKind.Phone)).toBe(
1077
+ IDENTIFIER_MASK,
1078
+ );
1079
+ expect(maskIdentifier(value, MaskedIdentifierKind.Handle)).toBe(
1080
+ IDENTIFIER_MASK,
1081
+ );
1082
+ },
1083
+ );
1084
+ });
1085
+ });
1086
+
1087
+ /*
1088
+ * ---------------------------------------------------------------------------
1089
+ * (B) Responder resolution.
1090
+ *
1091
+ * TeamComplianceService was TEAM-SCOPED, which meant the three ways a responder
1092
+ * most commonly gets paged - attached directly, reached through a schedule
1093
+ * layer, substituted in by an override - were invisible to it. Each of those is
1094
+ * a separate test here, and each would have been a silent false green before.
1095
+ * ---------------------------------------------------------------------------
1096
+ */
1097
+ describe("responder resolution", () => {
1098
+ test("a user attached DIRECTLY to an escalation rule is a responder (team-scoping defect)", async () => {
1099
+ attachDirectly(USER_A_ID);
1100
+
1101
+ const readiness: UserReadiness = await onlyUser();
1102
+
1103
+ expect(readiness.userId.toString()).toBe(USER_A_ID.toString());
1104
+ expect(readiness.reachedVia).toEqual([ResponderSource.Direct]);
1105
+ });
1106
+
1107
+ test("a user reached only through a TEAM on an escalation rule is a responder", async () => {
1108
+ escalationTeamFindBy.mockResolvedValue([
1109
+ escalationTeamRow(TEAM_ID),
1110
+ ] as never);
1111
+ teamMemberRows = [teamMemberRow(USER_B_ID)];
1112
+
1113
+ const readiness: UserReadiness = await onlyUser();
1114
+
1115
+ expect(readiness.userId.toString()).toBe(USER_B_ID.toString());
1116
+ expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
1117
+ });
1118
+
1119
+ test("a user reached only through a SCHEDULE LAYER is a responder (team-scoping defect)", async () => {
1120
+ escalationScheduleFindBy.mockResolvedValue([
1121
+ escalationScheduleRow(SCHEDULE_ID),
1122
+ ] as never);
1123
+ scheduleLayerUserFindBy.mockResolvedValue([
1124
+ layerUserRow(USER_C_ID),
1125
+ ] as never);
1126
+
1127
+ const readiness: UserReadiness = await onlyUser();
1128
+
1129
+ expect(readiness.userId.toString()).toBe(USER_C_ID.toString());
1130
+ expect(readiness.reachedVia).toEqual([ResponderSource.Schedule]);
1131
+ });
1132
+
1133
+ test("a user an OVERRIDE routes pages to is a responder, even attached to nothing else", async () => {
1134
+ overrideFindBy.mockResolvedValue([overrideRow(USER_C_ID)] as never);
1135
+
1136
+ const readiness: UserReadiness = await onlyUser();
1137
+
1138
+ expect(readiness.userId.toString()).toBe(USER_C_ID.toString());
1139
+ expect(readiness.reachedVia).toEqual([ResponderSource.Override]);
1140
+ });
1141
+
1142
+ test("a user reached two ways appears ONCE and carries BOTH sources", async () => {
1143
+ attachDirectly(USER_A_ID);
1144
+ escalationTeamFindBy.mockResolvedValue([
1145
+ escalationTeamRow(TEAM_ID),
1146
+ ] as never);
1147
+ teamMemberRows = [teamMemberRow(USER_A_ID)];
1148
+
1149
+ const summary: ReadinessSummary = await policySummary();
1150
+
1151
+ expect(summary.users).toHaveLength(1);
1152
+ expect(summary.users[0]!.reachedVia).toEqual([
1153
+ ResponderSource.Direct,
1154
+ ResponderSource.Team,
1155
+ ]);
1156
+ });
1157
+
1158
+ test("a user reached all four ways carries all four sources in canonical order", async () => {
1159
+ attachDirectly(USER_A_ID);
1160
+ escalationTeamFindBy.mockResolvedValue([
1161
+ escalationTeamRow(TEAM_ID),
1162
+ ] as never);
1163
+ teamMemberRows = [teamMemberRow(USER_A_ID)];
1164
+ escalationScheduleFindBy.mockResolvedValue([
1165
+ escalationScheduleRow(SCHEDULE_ID),
1166
+ ] as never);
1167
+ scheduleLayerUserFindBy.mockResolvedValue([
1168
+ layerUserRow(USER_A_ID),
1169
+ ] as never);
1170
+ overrideFindBy.mockResolvedValue([overrideRow(USER_A_ID)] as never);
1171
+
1172
+ const readiness: UserReadiness = await onlyUser();
1173
+
1174
+ expect(readiness.reachedVia).toEqual([
1175
+ ResponderSource.Direct,
1176
+ ResponderSource.Team,
1177
+ ResponderSource.Schedule,
1178
+ ResponderSource.Override,
1179
+ ]);
1180
+ });
1181
+
1182
+ test("a user attached twice through the same door is still one responder", async () => {
1183
+ escalationUserFindBy.mockResolvedValue([
1184
+ escalationUserRow(USER_A_ID),
1185
+ escalationUserRow(USER_A_ID),
1186
+ ] as never);
1187
+
1188
+ const summary: ReadinessSummary = await policySummary();
1189
+
1190
+ expect(summary.users).toHaveLength(1);
1191
+ expect(summary.users[0]!.reachedVia).toEqual([ResponderSource.Direct]);
1192
+ });
1193
+
1194
+ test("an escalation row with no user, and an override routing to nobody, are ignored rather than crashing", async () => {
1195
+ escalationUserFindBy.mockResolvedValue([
1196
+ escalationUserRow(undefined),
1197
+ ] as never);
1198
+ overrideFindBy.mockResolvedValue([overrideRow(undefined)] as never);
1199
+
1200
+ const summary: ReadinessSummary = await policySummary();
1201
+
1202
+ expect(summary.users).toEqual([]);
1203
+ expect(summary.readyCount).toBe(0);
1204
+ expect(summary.notReachableCount).toBe(0);
1205
+ });
1206
+
1207
+ test("every team on the policy is expanded by ONE query, with duplicate team ids collapsed", async () => {
1208
+ escalationTeamFindBy.mockResolvedValue([
1209
+ escalationTeamRow(TEAM_ID),
1210
+ escalationTeamRow(OTHER_TEAM_ID),
1211
+ escalationTeamRow(TEAM_ID),
1212
+ ] as never);
1213
+ teamMemberRows = [teamMemberRow(USER_A_ID), teamMemberRow(USER_B_ID)];
1214
+
1215
+ const summary: ReadinessSummary = await policySummary();
1216
+
1217
+ expect(summary.users).toHaveLength(2);
1218
+
1219
+ const memberCalls: Array<FindByCall> = teamMemberFindBy.mock.calls.map(
1220
+ (call: Array<unknown>): FindByCall => {
1221
+ return call[0] as FindByCall;
1222
+ },
1223
+ );
1224
+
1225
+ expect(memberCalls).toHaveLength(1);
1226
+ expect(includedIds(memberCalls[0]!.query["teamId"])).toEqual([
1227
+ TEAM_ID.toString(),
1228
+ OTHER_TEAM_ID.toString(),
1229
+ ]);
1230
+ });
1231
+
1232
+ test("team members are NOT filtered on hasAcceptedInvitation, because the runtime pages them anyway", async () => {
1233
+ escalationTeamFindBy.mockResolvedValue([
1234
+ escalationTeamRow(TEAM_ID),
1235
+ ] as never);
1236
+ teamMemberRows = [teamMemberRow(USER_A_ID)];
1237
+
1238
+ await policySummary();
1239
+
1240
+ const query: Record<string, unknown> = firstCall(teamMemberFindBy).query;
1241
+
1242
+ expect(query["hasAcceptedInvitation"]).toBeUndefined();
1243
+ expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
1244
+ });
1245
+
1246
+ test("no teams on the policy means the member expansion is never asked for", async () => {
1247
+ attachDirectly(USER_A_ID);
1248
+
1249
+ await policySummary();
1250
+
1251
+ expect(teamMemberFindBy).not.toHaveBeenCalled();
1252
+ expect(scheduleLayerUserFindBy).not.toHaveBeenCalled();
1253
+ });
1254
+
1255
+ test("the policy scope filters all three escalation reads by policy id", async () => {
1256
+ attachDirectly(USER_A_ID);
1257
+
1258
+ await policySummary();
1259
+
1260
+ for (const spy of [
1261
+ escalationUserFindBy,
1262
+ escalationTeamFindBy,
1263
+ escalationScheduleFindBy,
1264
+ ]) {
1265
+ const query: Record<string, unknown> = firstCall(spy).query;
1266
+
1267
+ expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
1268
+ expect(query["onCallDutyPolicyId"]?.toString()).toBe(
1269
+ POLICY_ID.toString(),
1270
+ );
1271
+ }
1272
+ });
1273
+
1274
+ test("the project scope drops the policy filter entirely, so a user on ANY policy is included", async () => {
1275
+ attachDirectly(USER_A_ID);
1276
+
1277
+ const summary: ReadinessSummary =
1278
+ await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
1279
+
1280
+ expect(summary.onCallDutyPolicyId).toBeUndefined();
1281
+ expect(summary.projectId.toString()).toBe(PROJECT_ID.toString());
1282
+
1283
+ for (const spy of [
1284
+ escalationUserFindBy,
1285
+ escalationTeamFindBy,
1286
+ escalationScheduleFindBy,
1287
+ overrideFindBy,
1288
+ ]) {
1289
+ expect(firstCall(spy).query["onCallDutyPolicyId"]).toBeUndefined();
1290
+ }
1291
+ });
1292
+
1293
+ test("overrides are filtered to those that have not ENDED - an expired override pages nobody", async () => {
1294
+ attachDirectly(USER_A_ID);
1295
+
1296
+ await policySummary();
1297
+
1298
+ const endsAt: unknown = firstCall(overrideFindBy).query["endsAt"];
1299
+
1300
+ expect(endsAt).toBeInstanceOf(FindOperator);
1301
+ expect(
1302
+ (endsAt as FindOperator<unknown>).getSql!("override.endsAt"),
1303
+ ).toContain(">=");
1304
+ });
1305
+
1306
+ test("a GLOBAL override with a null policy id still counts against a specific policy", async () => {
1307
+ attachDirectly(USER_A_ID);
1308
+
1309
+ await policySummary();
1310
+
1311
+ const policyFilter: unknown =
1312
+ firstCall(overrideFindBy).query["onCallDutyPolicyId"];
1313
+
1314
+ /*
1315
+ * equalToOrNull, not equality. Filtering on equality alone would drop every
1316
+ * global override, which is the most commonly configured kind - and the
1317
+ * substitute they route to is the responder most likely to be silently
1318
+ * unreachable.
1319
+ */
1320
+ expect(policyFilter).toBeInstanceOf(FindOperator);
1321
+ expect(
1322
+ (policyFilter as FindOperator<unknown>).getSql!(
1323
+ "override.onCallDutyPolicyId",
1324
+ ),
1325
+ ).toContain("IS NULL");
1326
+ expect(
1327
+ Object.values(
1328
+ (policyFilter as FindOperator<unknown>).objectLiteralParameters || {},
1329
+ ),
1330
+ ).toContain(POLICY_ID.toString());
1331
+ });
1332
+
1333
+ test("a responder with no User row is dropped rather than reported as a nameless gap", async () => {
1334
+ attachDirectly(USER_A_ID);
1335
+ userDirectory = [];
1336
+
1337
+ const summary: ReadinessSummary = await policySummary();
1338
+
1339
+ expect(summary.users).toEqual([]);
1340
+ });
1341
+ });
1342
+
1343
+ /*
1344
+ * ---------------------------------------------------------------------------
1345
+ * (B2) WHICH teams page a responder.
1346
+ *
1347
+ * `reachedVia` has always said THAT a team was involved. It could not say which
1348
+ * one, because the resolution pass read exactly the membership rows that would
1349
+ * answer it and then dropped the team id on the floor - so the readiness table
1350
+ * could show a "Team" chip and could not be filtered down to a team.
1351
+ *
1352
+ * The semantics pinned here are narrower than "teams this person belongs to",
1353
+ * deliberately: a team with no escalation rule attached to it pages nobody, and
1354
+ * naming it would let an admin filter the table to that team and read a clean
1355
+ * answer about people it cannot reach. Every team named on a responder is a team
1356
+ * that both (a) they belong to and (b) is attached to an escalation rule in the
1357
+ * scope being computed - which is exactly the set that puts Team in reachedVia.
1358
+ * ---------------------------------------------------------------------------
1359
+ */
1360
+ describe("the teams that page a responder", () => {
1361
+ test("a responder reached through a team carries that team, named", async () => {
1362
+ escalationTeamFindBy.mockResolvedValue([
1363
+ escalationTeamRow(TEAM_ID),
1364
+ ] as never);
1365
+ teamMemberRows = [teamMemberRow(USER_B_ID, TEAM_ID)];
1366
+
1367
+ const readiness: UserReadiness = await onlyUser();
1368
+
1369
+ expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
1370
+ expect(readiness.teams).toHaveLength(1);
1371
+ expect(readiness.teams[0]!.name).toBe("Platform");
1372
+ expect(readiness.teams[0]!._id.toString()).toBe(TEAM_ID.toString());
1373
+ });
1374
+
1375
+ /*
1376
+ * A responder attached directly is reached without any team being involved, so
1377
+ * an empty list is the honest answer. Filling it with their project teams
1378
+ * would put them under a team filter they are not answerable to.
1379
+ */
1380
+ test("a responder reached directly carries no teams at all", async () => {
1381
+ attachDirectly(USER_A_ID);
1382
+
1383
+ const readiness: UserReadiness = await onlyUser();
1384
+
1385
+ expect(readiness.reachedVia).toEqual([ResponderSource.Direct]);
1386
+ expect(readiness.teams).toEqual([]);
1387
+ });
1388
+
1389
+ test("a responder reached only by a schedule carries no teams", async () => {
1390
+ escalationScheduleFindBy.mockResolvedValue([
1391
+ escalationScheduleRow(SCHEDULE_ID),
1392
+ ] as never);
1393
+ scheduleLayerUserFindBy.mockResolvedValue([
1394
+ layerUserRow(USER_C_ID),
1395
+ ] as never);
1396
+
1397
+ const readiness: UserReadiness = await onlyUser();
1398
+
1399
+ expect(readiness.teams).toEqual([]);
1400
+ });
1401
+
1402
+ /*
1403
+ * Both teams, because both page them. Removing them from one does not stop the
1404
+ * other, which is the same reasoning reachedVia carries every source rather
1405
+ * than the first one found - and it is what lets one responder answer a filter
1406
+ * for either team.
1407
+ */
1408
+ test("a responder on two attached teams carries both", async () => {
1409
+ escalationTeamFindBy.mockResolvedValue([
1410
+ escalationTeamRow(TEAM_ID),
1411
+ escalationTeamRow(OTHER_TEAM_ID),
1412
+ ] as never);
1413
+ teamMemberRows = [
1414
+ teamMemberRow(USER_A_ID, TEAM_ID),
1415
+ teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
1416
+ ];
1417
+
1418
+ const readiness: UserReadiness = await onlyUser();
1419
+
1420
+ expect(
1421
+ readiness.teams.map((team: ReadinessTeam): string => {
1422
+ return team.name;
1423
+ }),
1424
+ ).toEqual(["Payments", "Platform"]);
1425
+ });
1426
+
1427
+ /*
1428
+ * Sorted by NAME rather than by whichever membership row the database returned
1429
+ * first, so the column and the filter chip read the same way for every
1430
+ * responder and do not reshuffle between requests.
1431
+ */
1432
+ test("the teams are in name order, not in row order", async () => {
1433
+ escalationTeamFindBy.mockResolvedValue([
1434
+ escalationTeamRow(OTHER_TEAM_ID),
1435
+ escalationTeamRow(TEAM_ID),
1436
+ ] as never);
1437
+ teamMemberRows = [
1438
+ teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
1439
+ teamMemberRow(USER_A_ID, TEAM_ID),
1440
+ ];
1441
+
1442
+ const readiness: UserReadiness = await onlyUser();
1443
+
1444
+ expect(
1445
+ readiness.teams.map((team: ReadinessTeam): string => {
1446
+ return team.name;
1447
+ }),
1448
+ ).toEqual(["Payments", "Platform"]);
1449
+ });
1450
+
1451
+ test("the same team twice through two rows is carried once", async () => {
1452
+ escalationTeamFindBy.mockResolvedValue([
1453
+ escalationTeamRow(TEAM_ID),
1454
+ ] as never);
1455
+ teamMemberRows = [
1456
+ teamMemberRow(USER_A_ID, TEAM_ID),
1457
+ teamMemberRow(USER_A_ID, TEAM_ID),
1458
+ ];
1459
+
1460
+ const readiness: UserReadiness = await onlyUser();
1461
+
1462
+ expect(readiness.teams).toHaveLength(1);
1463
+ });
1464
+
1465
+ /*
1466
+ * ONE read for every team named anywhere in the responder set. A team on an
1467
+ * escalation rule is shared by every one of its members, so a per-member read
1468
+ * is the N+1 that made the report this service replaced unusable on a project
1469
+ * of any size.
1470
+ */
1471
+ test("team names are read once for the whole responder set, not once per member", async () => {
1472
+ escalationTeamFindBy.mockResolvedValue([
1473
+ escalationTeamRow(TEAM_ID),
1474
+ escalationTeamRow(OTHER_TEAM_ID),
1475
+ ] as never);
1476
+ teamMemberRows = [
1477
+ teamMemberRow(USER_A_ID, TEAM_ID),
1478
+ teamMemberRow(USER_B_ID, TEAM_ID),
1479
+ teamMemberRow(USER_C_ID, OTHER_TEAM_ID),
1480
+ ];
1481
+
1482
+ const summary: ReadinessSummary = await policySummary();
1483
+
1484
+ expect(summary.users).toHaveLength(3);
1485
+ expect(teamFindBy.mock.calls).toHaveLength(1);
1486
+ });
1487
+
1488
+ /*
1489
+ * Nobody is reached through a team, so there is nothing to name. A read issued
1490
+ * anyway would be a query per page load buying nothing.
1491
+ */
1492
+ test("no team read at all when no team pages anybody", async () => {
1493
+ attachDirectly(USER_A_ID);
1494
+
1495
+ await policySummary();
1496
+
1497
+ expect(teamFindBy).not.toHaveBeenCalled();
1498
+ });
1499
+
1500
+ /*
1501
+ * A team whose row did not come back is DROPPED rather than rendered as a bare
1502
+ * id. This list is what builds the readiness table's team filter, and an option
1503
+ * nobody can read is an option nobody can choose on purpose - whereas a chip
1504
+ * showing a uuid is one an admin might act on.
1505
+ */
1506
+ test("a team whose row did not come back is dropped rather than shown nameless", async () => {
1507
+ escalationTeamFindBy.mockResolvedValue([
1508
+ escalationTeamRow(TEAM_ID),
1509
+ escalationTeamRow(OTHER_TEAM_ID),
1510
+ ] as never);
1511
+ teamMemberRows = [
1512
+ teamMemberRow(USER_A_ID, TEAM_ID),
1513
+ teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
1514
+ ];
1515
+ teamDirectory = [teamRow(TEAM_ID, "Platform")];
1516
+
1517
+ const readiness: UserReadiness = await onlyUser();
1518
+
1519
+ expect(
1520
+ readiness.teams.map((team: ReadinessTeam): string => {
1521
+ return team.name;
1522
+ }),
1523
+ ).toEqual(["Platform"]);
1524
+ });
1525
+
1526
+ test("the team read is scoped to the project and to the teams that page somebody", async () => {
1527
+ escalationTeamFindBy.mockResolvedValue([
1528
+ escalationTeamRow(TEAM_ID),
1529
+ ] as never);
1530
+ teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
1531
+
1532
+ await policySummary();
1533
+
1534
+ const query: Record<string, unknown> = firstCall(teamFindBy).query;
1535
+
1536
+ expect(query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
1537
+ expect(includedIds(query["_id"] as Includes)).toEqual([TEAM_ID.toString()]);
1538
+ });
1539
+
1540
+ /*
1541
+ * The per-USER entry point resolves responders the other way round - from the
1542
+ * user's own memberships inwards - and it has to reach the same answer. Two
1543
+ * paths that disagree would show a responder a different team list depending on
1544
+ * whether an admin opened the readiness table or that responder's own card,
1545
+ * and each answer would sit in its own cache for a minute.
1546
+ */
1547
+ test("the per-user path names the same teams as the project path", async () => {
1548
+ escalationTeamFindBy.mockResolvedValue([
1549
+ escalationTeamRow(TEAM_ID),
1550
+ ] as never);
1551
+ membershipRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
1552
+
1553
+ const readiness: UserReadiness | null =
1554
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
1555
+
1556
+ expect(readiness).not.toBeNull();
1557
+ expect(readiness!.reachedVia).toEqual([ResponderSource.Team]);
1558
+ expect(
1559
+ readiness!.teams.map((team: ReadinessTeam): string => {
1560
+ return team.name;
1561
+ }),
1562
+ ).toEqual(["Platform"]);
1563
+ });
1564
+
1565
+ /*
1566
+ * A team the user belongs to but which is attached to no escalation rule pages
1567
+ * nobody, so it must not appear - on either path. This is the assertion that
1568
+ * makes "teams that page you" different from "teams you are in".
1569
+ */
1570
+ test("the per-user path leaves out a team that is attached to nothing", async () => {
1571
+ escalationTeamFindBy.mockResolvedValue([
1572
+ escalationTeamRow(TEAM_ID),
1573
+ ] as never);
1574
+ membershipRows = [
1575
+ teamMemberRow(USER_A_ID, TEAM_ID),
1576
+ teamMemberRow(USER_A_ID, OTHER_TEAM_ID),
1577
+ ];
1578
+
1579
+ const readiness: UserReadiness | null =
1580
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
1581
+
1582
+ expect(
1583
+ readiness!.teams.map((team: ReadinessTeam): string => {
1584
+ return team.name;
1585
+ }),
1586
+ ).toEqual(["Platform"]);
1587
+ });
1588
+ });
1589
+
1590
+ /*
1591
+ * ---------------------------------------------------------------------------
1592
+ * (C) The status calculation, at every boundary.
1593
+ * ---------------------------------------------------------------------------
1594
+ */
1595
+ describe("status", () => {
1596
+ beforeEach(() => {
1597
+ attachDirectly(USER_A_ID);
1598
+ });
1599
+
1600
+ test("zero notification methods is NotReachable, and says so before it says anything else", async () => {
1601
+ const readiness: UserReadiness = await onlyUser();
1602
+
1603
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
1604
+ expect(readiness.methods).toEqual([]);
1605
+ expect(readiness.reasons[0]).toBe(
1606
+ "No verified notification method - cannot be paged",
1607
+ );
1608
+ expect(readiness.reasons[1]).toContain(
1609
+ "add and verify a notification method",
1610
+ );
1611
+ });
1612
+
1613
+ test("methods that exist but are UNVERIFIED are still NotReachable, and are named so the fix is one click", async () => {
1614
+ emailFindBy.mockResolvedValue([
1615
+ emailMethod({
1616
+ userId: USER_A_ID,
1617
+ email: RAW_NOTIFICATION_EMAIL,
1618
+ isVerified: false,
1619
+ }),
1620
+ ] as never);
1621
+ smsFindBy.mockResolvedValue([
1622
+ smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: false }),
1623
+ ] as never);
1624
+
1625
+ const readiness: UserReadiness = await onlyUser();
1626
+
1627
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
1628
+ expect(readiness.reasons[1]).toBe(
1629
+ "Added Email, SMS but never verified - unverified methods are never used",
1630
+ );
1631
+ });
1632
+
1633
+ test("an unverified method is still LISTED, because the UI has to show what to go and verify", async () => {
1634
+ emailFindBy.mockResolvedValue([
1635
+ emailMethod({
1636
+ userId: USER_A_ID,
1637
+ email: RAW_NOTIFICATION_EMAIL,
1638
+ isVerified: false,
1639
+ }),
1640
+ ] as never);
1641
+
1642
+ const readiness: UserReadiness = await onlyUser();
1643
+
1644
+ expect(readiness.methods).toHaveLength(1);
1645
+ expect(readiness.methods[0]!.isVerified).toBe(false);
1646
+ });
1647
+
1648
+ test("NotReachable suppresses the coverage prose but NOT the coverage grid itself", async () => {
1649
+ setSeverities({
1650
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1651
+ });
1652
+
1653
+ const readiness: UserReadiness = await onlyUser();
1654
+
1655
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
1656
+ expect(readiness.coverage).toHaveLength(2);
1657
+ expect(
1658
+ readiness.reasons.some((reason: string): boolean => {
1659
+ return reason.includes("No rules for");
1660
+ }),
1661
+ ).toBe(false);
1662
+ });
1663
+
1664
+ test("a WEBHOOK alone is enough to be reachable - it has no verification concept at all", async () => {
1665
+ webhookFindBy.mockResolvedValue([
1666
+ webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
1667
+ ] as never);
1668
+
1669
+ const readiness: UserReadiness = await onlyUser();
1670
+
1671
+ expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
1672
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1673
+ expect(readiness.methods[0]!.methodType).toBe(ReadinessMethodType.Webhook);
1674
+ expect(readiness.methods[0]!.isVerified).toBe(true);
1675
+ });
1676
+
1677
+ test("ONE uncovered cell is PartiallyReady - the other three of four cells do not rescue it", async () => {
1678
+ setSeverities({
1679
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1680
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
1681
+ });
1682
+ pushFindBy.mockResolvedValue([
1683
+ pushMethod({
1684
+ userId: USER_A_ID,
1685
+ deviceName: RAW_PUSH_DEVICE,
1686
+ isVerified: true,
1687
+ }),
1688
+ ] as never);
1689
+ notificationRuleFindBy.mockResolvedValue([
1690
+ notificationRule({
1691
+ userId: USER_A_ID,
1692
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
1693
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1694
+ }),
1695
+ notificationRule({
1696
+ userId: USER_A_ID,
1697
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
1698
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1699
+ }),
1700
+ notificationRule({
1701
+ userId: USER_A_ID,
1702
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
1703
+ alertSeverityId: ALERT_SEVERITY_1_ID,
1704
+ }),
1705
+ ] as never);
1706
+
1707
+ const readiness: UserReadiness = await onlyUser();
1708
+
1709
+ expect(readiness.coverage).toHaveLength(4);
1710
+ expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
1711
+ expect(readiness.reasons).toEqual([
1712
+ "No rules for Warning alert episodes - pages fall back to Push",
1713
+ ]);
1714
+ });
1715
+
1716
+ test("every cell covered is Ready, with nothing left to say", async () => {
1717
+ setSeverities({
1718
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1719
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
1720
+ });
1721
+ pushFindBy.mockResolvedValue([
1722
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1723
+ ] as never);
1724
+ notificationRuleFindBy.mockResolvedValue([
1725
+ notificationRule({
1726
+ userId: USER_A_ID,
1727
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
1728
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1729
+ }),
1730
+ notificationRule({
1731
+ userId: USER_A_ID,
1732
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
1733
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1734
+ }),
1735
+ notificationRule({
1736
+ userId: USER_A_ID,
1737
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
1738
+ alertSeverityId: ALERT_SEVERITY_1_ID,
1739
+ }),
1740
+ notificationRule({
1741
+ userId: USER_A_ID,
1742
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
1743
+ alertSeverityId: ALERT_SEVERITY_1_ID,
1744
+ }),
1745
+ ] as never);
1746
+
1747
+ const readiness: UserReadiness = await onlyUser();
1748
+
1749
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1750
+ expect(readiness.reasons).toEqual([]);
1751
+ });
1752
+
1753
+ test("every cell MUTED is Ready too - deliberate silence is not a gap", async () => {
1754
+ setSeverities({
1755
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1756
+ });
1757
+ pushFindBy.mockResolvedValue([
1758
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1759
+ ] as never);
1760
+ notificationRuleFindBy.mockResolvedValue([
1761
+ notificationRule({
1762
+ userId: USER_A_ID,
1763
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
1764
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1765
+ isOptOut: true,
1766
+ }),
1767
+ notificationRule({
1768
+ userId: USER_A_ID,
1769
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
1770
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1771
+ isOptOut: true,
1772
+ }),
1773
+ ] as never);
1774
+
1775
+ const readiness: UserReadiness = await onlyUser();
1776
+
1777
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1778
+ expect(readiness.reasons).toEqual([]);
1779
+ });
1780
+
1781
+ test("a mix of covered and muted cells is Ready", async () => {
1782
+ setSeverities({
1783
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1784
+ });
1785
+ pushFindBy.mockResolvedValue([
1786
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1787
+ ] as never);
1788
+ notificationRuleFindBy.mockResolvedValue([
1789
+ notificationRule({
1790
+ userId: USER_A_ID,
1791
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
1792
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1793
+ }),
1794
+ notificationRule({
1795
+ userId: USER_A_ID,
1796
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
1797
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
1798
+ isOptOut: true,
1799
+ }),
1800
+ ] as never);
1801
+
1802
+ const readiness: UserReadiness = await onlyUser();
1803
+
1804
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1805
+ });
1806
+
1807
+ test("NotReachable wins over an uncovered cell - nothing can reach them, so coverage is moot", async () => {
1808
+ setSeverities({
1809
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
1810
+ });
1811
+ telegramFindBy.mockResolvedValue([
1812
+ telegramMethod({
1813
+ userId: USER_A_ID,
1814
+ handle: RAW_TELEGRAM_HANDLE,
1815
+ isVerified: false,
1816
+ }),
1817
+ ] as never);
1818
+
1819
+ const readiness: UserReadiness = await onlyUser();
1820
+
1821
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
1822
+ });
1823
+
1824
+ /*
1825
+ * A verified method on a channel the project has switched off is not a way to
1826
+ * reach anybody. Status used to be computed from `isVerified` alone, which
1827
+ * meant these responders rendered Ready and GREEN while being completely
1828
+ * unpageable - the single most dangerous output this service can produce,
1829
+ * because the whole point of the table is that a green row can be trusted.
1830
+ */
1831
+ describe("project channel switches", () => {
1832
+ test("verified SMS and Call, both switched off, is NotReachable rather than Ready", async () => {
1833
+ projectFindOneById.mockResolvedValue(
1834
+ makeProject({
1835
+ enableSmsNotifications: false,
1836
+ enableCallNotifications: false,
1837
+ }) as never,
1838
+ );
1839
+ smsFindBy.mockResolvedValue([
1840
+ smsMethod({
1841
+ userId: USER_A_ID,
1842
+ phone: RAW_SMS_PHONE,
1843
+ isVerified: true,
1844
+ }),
1845
+ ] as never);
1846
+ callFindBy.mockResolvedValue([
1847
+ callMethod({
1848
+ userId: USER_A_ID,
1849
+ phone: RAW_CALL_PHONE,
1850
+ isVerified: true,
1851
+ }),
1852
+ ] as never);
1853
+
1854
+ const readiness: UserReadiness = await onlyUser();
1855
+
1856
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
1857
+ expect(readiness.reasons[1]).toBe(
1858
+ "Every method they have verified is on SMS, Call, and this project has those channels switched off - that is a project setting, not something this user can fix",
1859
+ );
1860
+ });
1861
+
1862
+ test("the reason names the channel AND says whose problem it is, because the user cannot fix it", async () => {
1863
+ projectFindOneById.mockResolvedValue(
1864
+ makeProject({ enableWhatsAppNotifications: false }) as never,
1865
+ );
1866
+ whatsAppFindBy.mockResolvedValue([
1867
+ whatsAppMethod({
1868
+ userId: USER_A_ID,
1869
+ phone: RAW_WHATSAPP_PHONE,
1870
+ isVerified: true,
1871
+ }),
1872
+ ] as never);
1873
+
1874
+ const readiness: UserReadiness = await onlyUser();
1875
+
1876
+ /*
1877
+ * "Add and verify a notification method" is the WRONG instruction here -
1878
+ * they have done that, and doing it again on the same channel changes
1879
+ * nothing. The sentence has to send an admin to the project settings.
1880
+ */
1881
+ expect(readiness.reasons[1]).toContain("WhatsApp");
1882
+ expect(readiness.reasons[1]).toContain("project setting");
1883
+ expect(
1884
+ readiness.reasons.some((reason: string): boolean => {
1885
+ return reason.includes("add and verify");
1886
+ }),
1887
+ ).toBe(false);
1888
+ });
1889
+
1890
+ test("the methods are still LISTED - an admin has to see what is stranded", async () => {
1891
+ projectFindOneById.mockResolvedValue(
1892
+ makeProject({ enableTelegramNotifications: false }) as never,
1893
+ );
1894
+ telegramFindBy.mockResolvedValue([
1895
+ telegramMethod({
1896
+ userId: USER_A_ID,
1897
+ handle: RAW_TELEGRAM_HANDLE,
1898
+ isVerified: true,
1899
+ }),
1900
+ ] as never);
1901
+
1902
+ const readiness: UserReadiness = await onlyUser();
1903
+
1904
+ expect(methodTypes(readiness)).toEqual([ReadinessMethodType.Telegram]);
1905
+ expect(readiness.methods[0]!.isVerified).toBe(true);
1906
+ });
1907
+
1908
+ test("one working channel is enough - a stranded second channel does not make them unreachable", async () => {
1909
+ projectFindOneById.mockResolvedValue(
1910
+ makeProject({ enableSmsNotifications: false }) as never,
1911
+ );
1912
+ pushFindBy.mockResolvedValue([
1913
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1914
+ ] as never);
1915
+ smsFindBy.mockResolvedValue([
1916
+ smsMethod({
1917
+ userId: USER_A_ID,
1918
+ phone: RAW_SMS_PHONE,
1919
+ isVerified: true,
1920
+ }),
1921
+ ] as never);
1922
+
1923
+ const readiness: UserReadiness = await onlyUser();
1924
+
1925
+ /*
1926
+ * And NOTHING is said about the switched-off SMS. Saying it would put a
1927
+ * warning sentence on every user in a project that has switched SMS off,
1928
+ * attached to people who have nothing wrong with them - which is how a
1929
+ * readiness surface teaches admins to stop reading it.
1930
+ */
1931
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1932
+ expect(readiness.reasons).toEqual([]);
1933
+ });
1934
+
1935
+ test.each([
1936
+ [
1937
+ "Push",
1938
+ (): void => {
1939
+ pushFindBy.mockResolvedValue([
1940
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1941
+ ] as never);
1942
+ },
1943
+ ],
1944
+ [
1945
+ "Email",
1946
+ (): void => {
1947
+ emailFindBy.mockResolvedValue([
1948
+ emailMethod({
1949
+ userId: USER_A_ID,
1950
+ email: RAW_NOTIFICATION_EMAIL,
1951
+ isVerified: true,
1952
+ }),
1953
+ ] as never);
1954
+ },
1955
+ ],
1956
+ [
1957
+ "Webhook",
1958
+ (): void => {
1959
+ webhookFindBy.mockResolvedValue([
1960
+ webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
1961
+ ] as never);
1962
+ },
1963
+ ],
1964
+ ])(
1965
+ "%s has no project switch to be gated by, so every switch being off leaves it usable",
1966
+ async (_label: string, give: () => void) => {
1967
+ projectFindOneById.mockResolvedValue(
1968
+ makeProject({
1969
+ enableSmsNotifications: false,
1970
+ enableCallNotifications: false,
1971
+ enableWhatsAppNotifications: false,
1972
+ enableTelegramNotifications: false,
1973
+ }) as never,
1974
+ );
1975
+ give();
1976
+
1977
+ expect((await onlyUser()).status).toBe(ReadinessStatus.Ready);
1978
+ },
1979
+ );
1980
+ });
1981
+
1982
+ test("a project with no severities configured has no cells, so a reachable responder is Ready", async () => {
1983
+ pushFindBy.mockResolvedValue([
1984
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
1985
+ ] as never);
1986
+
1987
+ const readiness: UserReadiness = await onlyUser();
1988
+
1989
+ expect(readiness.coverage).toEqual([]);
1990
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
1991
+ });
1992
+
1993
+ test("the user's display name falls back to their login email, then to a placeholder", async () => {
1994
+ const nameless: User = new User();
1995
+ nameless.id = USER_A_ID;
1996
+ nameless.email = new Email(USER_A_LOGIN_EMAIL);
1997
+
1998
+ const anonymous: User = new User();
1999
+ anonymous.id = USER_B_ID;
2000
+
2001
+ userDirectory = [nameless, anonymous];
2002
+ attachDirectly(USER_A_ID, USER_B_ID);
2003
+
2004
+ const summary: ReadinessSummary = await policySummary();
2005
+ const names: Array<string> = summary.users.map(
2006
+ (user: UserReadiness): string => {
2007
+ return user.userName;
2008
+ },
2009
+ );
2010
+
2011
+ expect(names).toContain(USER_A_LOGIN_EMAIL);
2012
+ expect(names).toContain("Unknown User");
2013
+ });
2014
+ });
2015
+
2016
+ /*
2017
+ * ---------------------------------------------------------------------------
2018
+ * (D) Summary roll-up: counts and ordering.
2019
+ * ---------------------------------------------------------------------------
2020
+ */
2021
+ describe("summary roll-up", () => {
2022
+ beforeEach(() => {
2023
+ attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
2024
+ setSeverities({
2025
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
2026
+ });
2027
+
2028
+ /*
2029
+ * One responder in each of the three states, so the counts and the sort can
2030
+ * be read off the same fixture:
2031
+ * Ada - verified push, every cell covered -> Ready
2032
+ * Grace - verified push, nothing covered -> PartiallyReady
2033
+ * Katherine - nothing at all -> NotReachable
2034
+ */
2035
+ pushFindBy.mockResolvedValue([
2036
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2037
+ pushMethod({ userId: USER_B_ID, isVerified: true }),
2038
+ ] as never);
2039
+ notificationRuleFindBy.mockResolvedValue([
2040
+ notificationRule({
2041
+ userId: USER_A_ID,
2042
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2043
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2044
+ }),
2045
+ notificationRule({
2046
+ userId: USER_A_ID,
2047
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
2048
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2049
+ }),
2050
+ ] as never);
2051
+ });
2052
+
2053
+ test("each status is counted exactly once, and the counts sum to the user list", async () => {
2054
+ const summary: ReadinessSummary = await policySummary();
2055
+
2056
+ expect(summary.readyCount).toBe(1);
2057
+ expect(summary.partiallyReadyCount).toBe(1);
2058
+ expect(summary.notReachableCount).toBe(1);
2059
+ expect(
2060
+ summary.readyCount +
2061
+ summary.partiallyReadyCount +
2062
+ summary.notReachableCount,
2063
+ ).toBe(summary.users.length);
2064
+ });
2065
+
2066
+ test("the most broken responder sorts first - the table is read by somebody looking for a problem", async () => {
2067
+ const summary: ReadinessSummary = await policySummary();
2068
+
2069
+ expect(
2070
+ summary.users.map((user: UserReadiness): ReadinessStatus => {
2071
+ return user.status;
2072
+ }),
2073
+ ).toEqual([
2074
+ ReadinessStatus.NotReachable,
2075
+ ReadinessStatus.PartiallyReady,
2076
+ ReadinessStatus.Ready,
2077
+ ]);
2078
+ });
2079
+
2080
+ test("responders of equal status sort by name, so the list is stable between renders", async () => {
2081
+ pushFindBy.mockResolvedValue([] as never);
2082
+
2083
+ const summary: ReadinessSummary = await policySummary();
2084
+
2085
+ expect(
2086
+ summary.users.map((user: UserReadiness): string => {
2087
+ return user.userName;
2088
+ }),
2089
+ ).toEqual(["Ada Lovelace", "Grace Hopper", "Katherine Johnson"]);
2090
+ });
2091
+
2092
+ test("the summary echoes back the scope it was asked about", async () => {
2093
+ const summary: ReadinessSummary = await policySummary();
2094
+
2095
+ expect(summary.projectId.toString()).toBe(PROJECT_ID.toString());
2096
+ expect(summary.onCallDutyPolicyId?.toString()).toBe(POLICY_ID.toString());
2097
+ });
2098
+ });
2099
+
2100
+ /*
2101
+ * ---------------------------------------------------------------------------
2102
+ * (E) Coverage: the right severity model for the right rule type.
2103
+ *
2104
+ * Incident and incident-episode rules are scoped by IncidentSeverity; alert and
2105
+ * alert-episode by AlertSeverity. Crossing them is not a cosmetic mistake - it
2106
+ * produces a cell keyed on an id that the paging path will never present, so
2107
+ * the cell can NEVER be satisfied, and a rule written against the wrong column
2108
+ * silently covers nothing. That is the exact shape of Gap G.
2109
+ * ---------------------------------------------------------------------------
2110
+ */
2111
+ describe("coverage", () => {
2112
+ beforeEach(() => {
2113
+ attachDirectly(USER_A_ID);
2114
+ pushFindBy.mockResolvedValue([
2115
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2116
+ ] as never);
2117
+ setSeverities({
2118
+ incident: [
2119
+ incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
2120
+ incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
2121
+ ],
2122
+ alert: [
2123
+ alertSeverity(ALERT_SEVERITY_1_ID, "Warning"),
2124
+ alertSeverity(ALERT_SEVERITY_2_ID, "Critical"),
2125
+ ],
2126
+ });
2127
+ });
2128
+
2129
+ test("the grid is the four PAGING rule types crossed with their own severity list", async () => {
2130
+ const readiness: UserReadiness = await onlyUser();
2131
+
2132
+ expect(readiness.coverage).toHaveLength(8);
2133
+
2134
+ const byRuleType: Map<NotificationRuleType, Array<string>> = new Map<
2135
+ NotificationRuleType,
2136
+ Array<string>
2137
+ >();
2138
+
2139
+ for (const cell of readiness.coverage) {
2140
+ const existing: Array<string> = byRuleType.get(cell.ruleType) || [];
2141
+ existing.push(cell.severityId?.toString() || "");
2142
+ byRuleType.set(cell.ruleType, existing);
2143
+ }
2144
+
2145
+ expect(
2146
+ byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_INCIDENT),
2147
+ ).toEqual([
2148
+ INCIDENT_SEVERITY_1_ID.toString(),
2149
+ INCIDENT_SEVERITY_2_ID.toString(),
2150
+ ]);
2151
+ expect(
2152
+ byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE),
2153
+ ).toEqual([
2154
+ INCIDENT_SEVERITY_1_ID.toString(),
2155
+ INCIDENT_SEVERITY_2_ID.toString(),
2156
+ ]);
2157
+ expect(byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_ALERT)).toEqual(
2158
+ [ALERT_SEVERITY_1_ID.toString(), ALERT_SEVERITY_2_ID.toString()],
2159
+ );
2160
+ expect(
2161
+ byRuleType.get(NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE),
2162
+ ).toEqual([ALERT_SEVERITY_1_ID.toString(), ALERT_SEVERITY_2_ID.toString()]);
2163
+ });
2164
+
2165
+ test("no alert cell is ever keyed on an incident severity, and vice versa", async () => {
2166
+ const readiness: UserReadiness = await onlyUser();
2167
+
2168
+ const incidentIds: Set<string> = new Set<string>([
2169
+ INCIDENT_SEVERITY_1_ID.toString(),
2170
+ INCIDENT_SEVERITY_2_ID.toString(),
2171
+ ]);
2172
+ const alertIds: Set<string> = new Set<string>([
2173
+ ALERT_SEVERITY_1_ID.toString(),
2174
+ ALERT_SEVERITY_2_ID.toString(),
2175
+ ]);
2176
+
2177
+ for (const cell of readiness.coverage) {
2178
+ const isIncidentRule: boolean =
2179
+ cell.ruleType === NotificationRuleType.ON_CALL_EXECUTED_INCIDENT ||
2180
+ cell.ruleType ===
2181
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE;
2182
+ const id: string = cell.severityId?.toString() || "";
2183
+
2184
+ expect(isIncidentRule ? incidentIds.has(id) : alertIds.has(id)).toBe(
2185
+ true,
2186
+ );
2187
+ }
2188
+ });
2189
+
2190
+ test("cells carry the severity NAME, so a reason sentence can say Sev2 rather than a uuid", async () => {
2191
+ const readiness: UserReadiness = await onlyUser();
2192
+
2193
+ expect(
2194
+ cellFor(
2195
+ readiness,
2196
+ NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
2197
+ ALERT_SEVERITY_2_ID,
2198
+ ).severityName,
2199
+ ).toBe("Critical");
2200
+ });
2201
+
2202
+ test("a rule covers ONLY its own (ruleType, severity) cell", async () => {
2203
+ notificationRuleFindBy.mockResolvedValue([
2204
+ notificationRule({
2205
+ userId: USER_A_ID,
2206
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2207
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2208
+ }),
2209
+ ] as never);
2210
+
2211
+ const readiness: UserReadiness = await onlyUser();
2212
+
2213
+ expect(
2214
+ cellFor(
2215
+ readiness,
2216
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2217
+ INCIDENT_SEVERITY_1_ID,
2218
+ ).hasRule,
2219
+ ).toBe(true);
2220
+
2221
+ /*
2222
+ * The episode cell for the SAME severity is a different cell. Gap F was
2223
+ * exactly this: episode pages fired against rules that only ever existed
2224
+ * for the non-episode rule type.
2225
+ */
2226
+ expect(
2227
+ cellFor(
2228
+ readiness,
2229
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
2230
+ INCIDENT_SEVERITY_1_ID,
2231
+ ).hasRule,
2232
+ ).toBe(false);
2233
+ expect(
2234
+ cellFor(
2235
+ readiness,
2236
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2237
+ INCIDENT_SEVERITY_2_ID,
2238
+ ).hasRule,
2239
+ ).toBe(false);
2240
+ });
2241
+
2242
+ test("a handoff rule covers NOTHING, however its severity is set (severity-only matching defect)", async () => {
2243
+ notificationRuleFindBy.mockResolvedValue([
2244
+ notificationRule({
2245
+ userId: USER_A_ID,
2246
+ ruleType: NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
2247
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2248
+ alertSeverityId: ALERT_SEVERITY_1_ID,
2249
+ }),
2250
+ notificationRule({
2251
+ userId: USER_A_ID,
2252
+ ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
2253
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2254
+ }),
2255
+ ] as never);
2256
+
2257
+ const readiness: UserReadiness = await onlyUser();
2258
+
2259
+ expect(
2260
+ readiness.coverage.every((cell: ReadinessCoverageCell): boolean => {
2261
+ return !cell.hasRule && !cell.isOptOut;
2262
+ }),
2263
+ ).toBe(true);
2264
+ expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
2265
+ });
2266
+
2267
+ test("an ALERT rule carrying only an incidentSeverityId covers nothing - it matches no page at runtime either", async () => {
2268
+ notificationRuleFindBy.mockResolvedValue([
2269
+ notificationRule({
2270
+ userId: USER_A_ID,
2271
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
2272
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2273
+ }),
2274
+ ] as never);
2275
+
2276
+ const readiness: UserReadiness = await onlyUser();
2277
+
2278
+ expect(
2279
+ readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
2280
+ return cell.hasRule;
2281
+ }),
2282
+ ).toBe(false);
2283
+ });
2284
+
2285
+ test("an INCIDENT rule whose incidentSeverityId is an ALERT severity id matches no cell", async () => {
2286
+ notificationRuleFindBy.mockResolvedValue([
2287
+ notificationRule({
2288
+ userId: USER_A_ID,
2289
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2290
+ incidentSeverityId: ALERT_SEVERITY_1_ID,
2291
+ }),
2292
+ ] as never);
2293
+
2294
+ const readiness: UserReadiness = await onlyUser();
2295
+
2296
+ expect(
2297
+ readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
2298
+ return cell.hasRule;
2299
+ }),
2300
+ ).toBe(false);
2301
+ });
2302
+
2303
+ test("a severity-scoped rule with a NULL severity is not coverage - it is the Gap G corpse", async () => {
2304
+ notificationRuleFindBy.mockResolvedValue([
2305
+ notificationRule({
2306
+ userId: USER_A_ID,
2307
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
2308
+ }),
2309
+ ] as never);
2310
+
2311
+ const readiness: UserReadiness = await onlyUser();
2312
+
2313
+ expect(
2314
+ readiness.coverage.some((cell: ReadinessCoverageCell): boolean => {
2315
+ return cell.hasRule;
2316
+ }),
2317
+ ).toBe(false);
2318
+ expect(readiness.status).toBe(ReadinessStatus.PartiallyReady);
2319
+ });
2320
+
2321
+ test("one user's rules never satisfy another user's cells", async () => {
2322
+ attachDirectly(USER_A_ID, USER_B_ID);
2323
+ pushFindBy.mockResolvedValue([
2324
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2325
+ pushMethod({ userId: USER_B_ID, isVerified: true }),
2326
+ ] as never);
2327
+ notificationRuleFindBy.mockResolvedValue([
2328
+ notificationRule({
2329
+ userId: USER_A_ID,
2330
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2331
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2332
+ }),
2333
+ ] as never);
2334
+
2335
+ const summary: ReadinessSummary = await policySummary();
2336
+ const grace: UserReadiness = summary.users.find(
2337
+ (user: UserReadiness): boolean => {
2338
+ return user.userId.toString() === USER_B_ID.toString();
2339
+ },
2340
+ )!;
2341
+
2342
+ expect(
2343
+ cellFor(
2344
+ grace,
2345
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2346
+ INCIDENT_SEVERITY_1_ID,
2347
+ ).hasRule,
2348
+ ).toBe(false);
2349
+ });
2350
+
2351
+ test("a severity with no name still produces a usable cell rather than being dropped", async () => {
2352
+ const unnamed: IncidentSeverity = new IncidentSeverity();
2353
+ unnamed.id = INCIDENT_SEVERITY_1_ID;
2354
+
2355
+ setSeverities({ incident: [unnamed] });
2356
+
2357
+ const readiness: UserReadiness = await onlyUser();
2358
+
2359
+ expect(
2360
+ cellFor(
2361
+ readiness,
2362
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2363
+ INCIDENT_SEVERITY_1_ID,
2364
+ ).severityName,
2365
+ ).toBe("Unnamed Severity");
2366
+ });
2367
+ });
2368
+
2369
+ /*
2370
+ * ---------------------------------------------------------------------------
2371
+ * (F) Opt-out: the nullable column that ruins everything if you test it for
2372
+ * `false`.
2373
+ * ---------------------------------------------------------------------------
2374
+ */
2375
+ describe("opt-out", () => {
2376
+ beforeEach(() => {
2377
+ attachDirectly(USER_A_ID);
2378
+ pushFindBy.mockResolvedValue([
2379
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2380
+ ] as never);
2381
+ setSeverities({
2382
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
2383
+ });
2384
+ });
2385
+
2386
+ test("a rule with isOptOut NULL is a REAL rule and counts as coverage", async () => {
2387
+ /*
2388
+ * THE test this file exists for as much as any other. isOptOut is nullable
2389
+ * and was added long after these rows started existing, so it is NULL on
2390
+ * every rule in every pre-existing install. The naive `isOptOut === false`
2391
+ * split would classify all of them as neither rules nor opt-outs and report
2392
+ * a fully-configured project as entirely unready - a red table that teaches
2393
+ * admins to ignore red tables.
2394
+ */
2395
+ notificationRuleFindBy.mockResolvedValue([
2396
+ notificationRule({
2397
+ userId: USER_A_ID,
2398
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2399
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2400
+ }),
2401
+ ] as never);
2402
+
2403
+ const readiness: UserReadiness = await onlyUser();
2404
+ const cell: ReadinessCoverageCell = cellFor(
2405
+ readiness,
2406
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2407
+ INCIDENT_SEVERITY_1_ID,
2408
+ );
2409
+
2410
+ expect(cell.hasRule).toBe(true);
2411
+ expect(cell.isOptOut).toBe(false);
2412
+ });
2413
+
2414
+ test("a rule with isOptOut explicitly false is also coverage", async () => {
2415
+ notificationRuleFindBy.mockResolvedValue([
2416
+ notificationRule({
2417
+ userId: USER_A_ID,
2418
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2419
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2420
+ isOptOut: false,
2421
+ }),
2422
+ ] as never);
2423
+
2424
+ expect(
2425
+ cellFor(
2426
+ await onlyUser(),
2427
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2428
+ INCIDENT_SEVERITY_1_ID,
2429
+ ).hasRule,
2430
+ ).toBe(true);
2431
+ });
2432
+
2433
+ test("an opt-out row is surfaced as isOptOut and is NOT counted as a rule", async () => {
2434
+ notificationRuleFindBy.mockResolvedValue([
2435
+ notificationRule({
2436
+ userId: USER_A_ID,
2437
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2438
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2439
+ isOptOut: true,
2440
+ }),
2441
+ ] as never);
2442
+
2443
+ const cell: ReadinessCoverageCell = cellFor(
2444
+ await onlyUser(),
2445
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2446
+ INCIDENT_SEVERITY_1_ID,
2447
+ );
2448
+
2449
+ expect(cell.isOptOut).toBe(true);
2450
+ expect(cell.hasRule).toBe(false);
2451
+ });
2452
+
2453
+ test("an opt-out cell is not a gap, so it never appears in a reason sentence", async () => {
2454
+ notificationRuleFindBy.mockResolvedValue([
2455
+ notificationRule({
2456
+ userId: USER_A_ID,
2457
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2458
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2459
+ isOptOut: true,
2460
+ }),
2461
+ ] as never);
2462
+
2463
+ const readiness: UserReadiness = await onlyUser();
2464
+
2465
+ expect(readiness.reasons).toEqual([
2466
+ "No rules for Sev1 incident episodes - pages fall back to Push",
2467
+ ]);
2468
+ });
2469
+
2470
+ test("a cell with BOTH an opt-out row and a real rule reports both, and is not a gap", async () => {
2471
+ notificationRuleFindBy.mockResolvedValue([
2472
+ notificationRule({
2473
+ userId: USER_A_ID,
2474
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2475
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2476
+ isOptOut: true,
2477
+ }),
2478
+ notificationRule({
2479
+ userId: USER_A_ID,
2480
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2481
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
2482
+ }),
2483
+ ] as never);
2484
+
2485
+ const cell: ReadinessCoverageCell = cellFor(
2486
+ await onlyUser(),
2487
+ NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
2488
+ INCIDENT_SEVERITY_1_ID,
2489
+ );
2490
+
2491
+ expect(cell.hasRule).toBe(true);
2492
+ expect(cell.isOptOut).toBe(true);
2493
+ });
2494
+
2495
+ test("opt-out rows are read alongside real rules, never filtered out in SQL", async () => {
2496
+ await policySummary();
2497
+
2498
+ const call: FindByCall = firstCall(notificationRuleFindBy);
2499
+
2500
+ /*
2501
+ * If the query filtered on isOptOut, the "muted" and "nothing configured"
2502
+ * cases would collapse into one - and telling them apart is the entire
2503
+ * reason the column exists.
2504
+ */
2505
+ expect(call.query["isOptOut"]).toBeUndefined();
2506
+ expect(call.select?.["isOptOut"]).toBe(true);
2507
+ });
2508
+ });
2509
+
2510
+ /*
2511
+ * ---------------------------------------------------------------------------
2512
+ * (G) All seven channels.
2513
+ *
2514
+ * TeamComplianceService counted call/SMS/email/push only, so a responder whose
2515
+ * only method was Telegram, WhatsApp or Webhook was reported non-compliant
2516
+ * while the runtime paged them perfectly happily. A false alarm teaches admins
2517
+ * to ignore the table, which is worse than no table.
2518
+ * ---------------------------------------------------------------------------
2519
+ */
2520
+ describe("notification channels", () => {
2521
+ beforeEach(() => {
2522
+ attachDirectly(USER_A_ID);
2523
+ });
2524
+
2525
+ test("a verified TELEGRAM handle alone makes a responder reachable (four-channel defect)", async () => {
2526
+ telegramFindBy.mockResolvedValue([
2527
+ telegramMethod({
2528
+ userId: USER_A_ID,
2529
+ handle: RAW_TELEGRAM_HANDLE,
2530
+ isVerified: true,
2531
+ }),
2532
+ ] as never);
2533
+
2534
+ const readiness: UserReadiness = await onlyUser();
2535
+
2536
+ expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
2537
+ expect(methodTypes(readiness)).toEqual([ReadinessMethodType.Telegram]);
2538
+ });
2539
+
2540
+ test("a verified WHATSAPP number alone makes a responder reachable (four-channel defect)", async () => {
2541
+ whatsAppFindBy.mockResolvedValue([
2542
+ whatsAppMethod({
2543
+ userId: USER_A_ID,
2544
+ phone: RAW_WHATSAPP_PHONE,
2545
+ isVerified: true,
2546
+ }),
2547
+ ] as never);
2548
+
2549
+ const readiness: UserReadiness = await onlyUser();
2550
+
2551
+ expect(readiness.status).not.toBe(ReadinessStatus.NotReachable);
2552
+ expect(methodTypes(readiness)).toEqual([ReadinessMethodType.WhatsApp]);
2553
+ });
2554
+
2555
+ test("a WEBHOOK alone makes a responder reachable (four-channel defect)", async () => {
2556
+ webhookFindBy.mockResolvedValue([
2557
+ webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
2558
+ ] as never);
2559
+
2560
+ expect((await onlyUser()).status).not.toBe(ReadinessStatus.NotReachable);
2561
+ });
2562
+
2563
+ test("all seven channels are read, and listed in fallback-attempt order", async () => {
2564
+ pushFindBy.mockResolvedValue([
2565
+ pushMethod({
2566
+ userId: USER_A_ID,
2567
+ deviceName: RAW_PUSH_DEVICE,
2568
+ isVerified: true,
2569
+ }),
2570
+ ] as never);
2571
+ emailFindBy.mockResolvedValue([
2572
+ emailMethod({
2573
+ userId: USER_A_ID,
2574
+ email: RAW_NOTIFICATION_EMAIL,
2575
+ isVerified: true,
2576
+ }),
2577
+ ] as never);
2578
+ smsFindBy.mockResolvedValue([
2579
+ smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: true }),
2580
+ ] as never);
2581
+ callFindBy.mockResolvedValue([
2582
+ callMethod({
2583
+ userId: USER_A_ID,
2584
+ phone: RAW_CALL_PHONE,
2585
+ isVerified: true,
2586
+ }),
2587
+ ] as never);
2588
+ whatsAppFindBy.mockResolvedValue([
2589
+ whatsAppMethod({
2590
+ userId: USER_A_ID,
2591
+ phone: RAW_WHATSAPP_PHONE,
2592
+ isVerified: true,
2593
+ }),
2594
+ ] as never);
2595
+ telegramFindBy.mockResolvedValue([
2596
+ telegramMethod({
2597
+ userId: USER_A_ID,
2598
+ handle: RAW_TELEGRAM_HANDLE,
2599
+ isVerified: true,
2600
+ }),
2601
+ ] as never);
2602
+ webhookFindBy.mockResolvedValue([
2603
+ webhookMethod({ userId: USER_A_ID, name: RAW_WEBHOOK_NAME }),
2604
+ ] as never);
2605
+
2606
+ const readiness: UserReadiness = await onlyUser();
2607
+
2608
+ /*
2609
+ * Display order IS fallback order, deliberately: the first row an admin
2610
+ * reads is the channel a fallback page would actually arrive on.
2611
+ */
2612
+ expect(methodTypes(readiness)).toEqual([
2613
+ ReadinessMethodType.Push,
2614
+ ReadinessMethodType.Email,
2615
+ ReadinessMethodType.SMS,
2616
+ ReadinessMethodType.Call,
2617
+ ReadinessMethodType.WhatsApp,
2618
+ ReadinessMethodType.Telegram,
2619
+ ReadinessMethodType.Webhook,
2620
+ ]);
2621
+ });
2622
+
2623
+ test("methods belonging to another responder are not attributed to this one", async () => {
2624
+ attachDirectly(USER_A_ID, USER_B_ID);
2625
+ pushFindBy.mockResolvedValue([
2626
+ pushMethod({ userId: USER_B_ID, isVerified: true }),
2627
+ ] as never);
2628
+
2629
+ const summary: ReadinessSummary = await policySummary();
2630
+ const ada: UserReadiness = summary.users.find(
2631
+ (user: UserReadiness): boolean => {
2632
+ return user.userId.toString() === USER_A_ID.toString();
2633
+ },
2634
+ )!;
2635
+
2636
+ expect(ada.methods).toEqual([]);
2637
+ expect(ada.status).toBe(ReadinessStatus.NotReachable);
2638
+ });
2639
+
2640
+ describe("fallback prose", () => {
2641
+ beforeEach(() => {
2642
+ setSeverities({
2643
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
2644
+ });
2645
+ });
2646
+
2647
+ test("both zero-cost channels are named when the responder has both", async () => {
2648
+ pushFindBy.mockResolvedValue([
2649
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2650
+ ] as never);
2651
+ emailFindBy.mockResolvedValue([
2652
+ emailMethod({
2653
+ userId: USER_A_ID,
2654
+ email: RAW_NOTIFICATION_EMAIL,
2655
+ isVerified: true,
2656
+ }),
2657
+ ] as never);
2658
+
2659
+ const readiness: UserReadiness = await onlyUser();
2660
+
2661
+ expect(readiness.reasons[0]).toBe(
2662
+ "No rules for Sev1 incidents - pages fall back to Push, Email",
2663
+ );
2664
+ });
2665
+
2666
+ test("a paid channel is named only while the project still has it switched on", async () => {
2667
+ smsFindBy.mockResolvedValue([
2668
+ smsMethod({
2669
+ userId: USER_A_ID,
2670
+ phone: RAW_SMS_PHONE,
2671
+ isVerified: true,
2672
+ }),
2673
+ ] as never);
2674
+
2675
+ expect((await onlyUser()).reasons[0]).toBe(
2676
+ "No rules for Sev1 incidents - pages fall back to SMS",
2677
+ );
2678
+ });
2679
+
2680
+ test("a responder whose only channels the project has switched off is NotReachable, not amber", async () => {
2681
+ projectFindOneById.mockResolvedValue(
2682
+ makeProject({ enableTelegramNotifications: false }) as never,
2683
+ );
2684
+ telegramFindBy.mockResolvedValue([
2685
+ telegramMethod({
2686
+ userId: USER_A_ID,
2687
+ handle: RAW_TELEGRAM_HANDLE,
2688
+ isVerified: true,
2689
+ }),
2690
+ ] as never);
2691
+
2692
+ const readiness: UserReadiness = await onlyUser();
2693
+
2694
+ /*
2695
+ * Nothing can reach this person. Their method is verified, so a status
2696
+ * computed from verification alone would call them Ready - and Ready is a
2697
+ * green chip next to a responder no page will ever arrive at. Verified is
2698
+ * necessary and not sufficient; usable is verified AND on a channel the
2699
+ * project actually sends on.
2700
+ */
2701
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
2702
+ expect(readiness.reasons[0]).toBe(
2703
+ "No usable notification method - cannot be paged",
2704
+ );
2705
+ expect(readiness.reasons[1]).toBe(
2706
+ "Every method they have verified is on Telegram, and this project has that channel switched off - that is a project setting, not something this user can fix",
2707
+ );
2708
+ });
2709
+
2710
+ test("with the fallback disabled, an uncovered cell means the page is DROPPED, not delayed", async () => {
2711
+ projectFindOneById.mockResolvedValue(
2712
+ makeProject({ disableOnCallNotificationFallback: true }) as never,
2713
+ );
2714
+ pushFindBy.mockResolvedValue([
2715
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2716
+ ] as never);
2717
+
2718
+ const readiness: UserReadiness = await onlyUser();
2719
+
2720
+ expect(readiness.reasons[0]).toBe(
2721
+ "No rules for Sev1 incidents - pages are dropped because on-call fallback is disabled for this project",
2722
+ );
2723
+ expect(
2724
+ readiness.reasons.some((reason: string): boolean => {
2725
+ return reason.includes("switched off");
2726
+ }),
2727
+ ).toBe(false);
2728
+ });
2729
+
2730
+ test("a missing project row is treated as every paid channel off, never as every channel on", async () => {
2731
+ projectFindOneById.mockResolvedValue(null as never);
2732
+ smsFindBy.mockResolvedValue([
2733
+ smsMethod({
2734
+ userId: USER_A_ID,
2735
+ phone: RAW_SMS_PHONE,
2736
+ isVerified: true,
2737
+ }),
2738
+ ] as never);
2739
+
2740
+ const readiness: UserReadiness = await onlyUser();
2741
+
2742
+ /*
2743
+ * A project row we could not read must never certify anyone as reachable
2744
+ * on a paid channel. Defaulting the switches to "on" would turn a failed
2745
+ * read into a green chip, which is the one direction this service is not
2746
+ * allowed to be wrong in.
2747
+ */
2748
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
2749
+ expect(readiness.reasons[1]).toContain("switched off");
2750
+ });
2751
+
2752
+ test("one sentence per rule type, with its missing severities listed rather than one line per cell", async () => {
2753
+ setSeverities({
2754
+ incident: [
2755
+ incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
2756
+ incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
2757
+ ],
2758
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
2759
+ });
2760
+ pushFindBy.mockResolvedValue([
2761
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
2762
+ ] as never);
2763
+
2764
+ const readiness: UserReadiness = await onlyUser();
2765
+
2766
+ expect(readiness.coverage).toHaveLength(6);
2767
+ expect(readiness.reasons).toEqual([
2768
+ "No rules for Sev1, Sev2 incidents - pages fall back to Push",
2769
+ "No rules for Sev1, Sev2 incident episodes - pages fall back to Push",
2770
+ "No rules for Warning alerts - pages fall back to Push",
2771
+ "No rules for Warning alert episodes - pages fall back to Push",
2772
+ ]);
2773
+ });
2774
+ });
2775
+ });
2776
+
2777
+ /*
2778
+ * ---------------------------------------------------------------------------
2779
+ * (G2) Method identity - referencing a method without reading it.
2780
+ *
2781
+ * The admin rule form has to POINT A RULE AT one of these methods, and it is not
2782
+ * allowed to read the row it points at: the seven method models are scoped to
2783
+ * their owner precisely because their columns are the raw phone number, the
2784
+ * webhook bearer url, the push device token, the telegram chat id and the
2785
+ * verification code. Widening that scope so a dropdown could be populated was
2786
+ * tried, and the exposure it opened could not be contained.
2787
+ *
2788
+ * `methodId` is what replaces it. The foreign key is not a secret - it is
2789
+ * already stored in plain sight on every rule its owner created, and it
2790
+ * addresses nothing on its own - so shipping it beside the mask is what lets an
2791
+ * admin select "SMS ending 4821" with the number itself never leaving the
2792
+ * server.
2793
+ *
2794
+ * Which makes these tests load-bearing in two directions at once: the id must be
2795
+ * PRESENT (or the form has nothing to submit) and it must be the id of the
2796
+ * METHOD row (or the form submits a userSmsId that is not a UserSMS).
2797
+ * ---------------------------------------------------------------------------
2798
+ */
2799
+ describe("method identity", () => {
2800
+ beforeEach(() => {
2801
+ attachDirectly(USER_A_ID);
2802
+ });
2803
+
2804
+ function attachEverySevenChannels(): void {
2805
+ pushFindBy.mockResolvedValue([
2806
+ pushMethod({
2807
+ userId: USER_A_ID,
2808
+ deviceName: RAW_PUSH_DEVICE,
2809
+ isVerified: true,
2810
+ id: PUSH_METHOD_ID,
2811
+ }),
2812
+ ] as never);
2813
+ emailFindBy.mockResolvedValue([
2814
+ emailMethod({
2815
+ userId: USER_A_ID,
2816
+ email: RAW_NOTIFICATION_EMAIL,
2817
+ isVerified: true,
2818
+ id: EMAIL_METHOD_ID,
2819
+ }),
2820
+ ] as never);
2821
+ smsFindBy.mockResolvedValue([
2822
+ smsMethod({
2823
+ userId: USER_A_ID,
2824
+ phone: RAW_SMS_PHONE,
2825
+ isVerified: true,
2826
+ id: SMS_METHOD_ID,
2827
+ }),
2828
+ ] as never);
2829
+ callFindBy.mockResolvedValue([
2830
+ callMethod({
2831
+ userId: USER_A_ID,
2832
+ phone: RAW_CALL_PHONE,
2833
+ isVerified: true,
2834
+ id: CALL_METHOD_ID,
2835
+ }),
2836
+ ] as never);
2837
+ whatsAppFindBy.mockResolvedValue([
2838
+ whatsAppMethod({
2839
+ userId: USER_A_ID,
2840
+ phone: RAW_WHATSAPP_PHONE,
2841
+ isVerified: true,
2842
+ id: WHATSAPP_METHOD_ID,
2843
+ }),
2844
+ ] as never);
2845
+ telegramFindBy.mockResolvedValue([
2846
+ telegramMethod({
2847
+ userId: USER_A_ID,
2848
+ handle: RAW_TELEGRAM_HANDLE,
2849
+ isVerified: true,
2850
+ id: TELEGRAM_METHOD_ID,
2851
+ }),
2852
+ ] as never);
2853
+ webhookFindBy.mockResolvedValue([
2854
+ webhookMethod({
2855
+ userId: USER_A_ID,
2856
+ name: RAW_WEBHOOK_NAME,
2857
+ webhookUrl: RAW_WEBHOOK_URL,
2858
+ id: WEBHOOK_METHOD_ID,
2859
+ }),
2860
+ ] as never);
2861
+ }
2862
+
2863
+ test("all seven channels carry the id of their OWN row, which is what a rule references", async () => {
2864
+ attachEverySevenChannels();
2865
+
2866
+ const readiness: UserReadiness = await onlyUser();
2867
+
2868
+ /*
2869
+ * One expectation per channel rather than a loop, so a failure names the
2870
+ * channel that lost its id rather than an index into an array.
2871
+ */
2872
+ expect(
2873
+ methodOfType(readiness, ReadinessMethodType.Push).methodId.toString(),
2874
+ ).toBe(PUSH_METHOD_ID.toString());
2875
+ expect(
2876
+ methodOfType(readiness, ReadinessMethodType.Email).methodId.toString(),
2877
+ ).toBe(EMAIL_METHOD_ID.toString());
2878
+ expect(
2879
+ methodOfType(readiness, ReadinessMethodType.SMS).methodId.toString(),
2880
+ ).toBe(SMS_METHOD_ID.toString());
2881
+ expect(
2882
+ methodOfType(readiness, ReadinessMethodType.Call).methodId.toString(),
2883
+ ).toBe(CALL_METHOD_ID.toString());
2884
+ expect(
2885
+ methodOfType(readiness, ReadinessMethodType.WhatsApp).methodId.toString(),
2886
+ ).toBe(WHATSAPP_METHOD_ID.toString());
2887
+ expect(
2888
+ methodOfType(readiness, ReadinessMethodType.Telegram).methodId.toString(),
2889
+ ).toBe(TELEGRAM_METHOD_ID.toString());
2890
+ expect(
2891
+ methodOfType(readiness, ReadinessMethodType.Webhook).methodId.toString(),
2892
+ ).toBe(WEBHOOK_METHOD_ID.toString());
2893
+ });
2894
+
2895
+ test("no methodId is the USER's id - a rule pointed at a user id points at no method at all", async () => {
2896
+ attachEverySevenChannels();
2897
+
2898
+ const readiness: UserReadiness = await onlyUser();
2899
+
2900
+ expect(readiness.methods).toHaveLength(7);
2901
+
2902
+ for (const method of readiness.methods) {
2903
+ /*
2904
+ * `row.userId` and `row.id` are both ObjectIDs on the same row, so
2905
+ * confusing them compiles, renders and reads correctly right up until the
2906
+ * saved rule turns out to reference a User rather than a UserSMS. This is
2907
+ * the assertion that separates them.
2908
+ */
2909
+ expect(method.methodId.toString()).not.toBe(USER_A_ID.toString());
2910
+ }
2911
+ });
2912
+
2913
+ test("two methods on the SAME channel are told apart by their ids, which is the whole point of a dropdown", async () => {
2914
+ /*
2915
+ * The case the admin form exists for: a responder with a work phone and a
2916
+ * personal phone. Both mask to something ending in four digits, and the
2917
+ * masks are all the admin can see, so the id is the only thing that makes
2918
+ * "the second one" selectable.
2919
+ */
2920
+ const secondSmsId: ObjectID = new ObjectID(
2921
+ "c0000000-0000-4000-8000-00000000000a",
2922
+ );
2923
+
2924
+ smsFindBy.mockResolvedValue([
2925
+ smsMethod({
2926
+ userId: USER_A_ID,
2927
+ phone: RAW_SMS_PHONE,
2928
+ isVerified: true,
2929
+ id: SMS_METHOD_ID,
2930
+ }),
2931
+ smsMethod({
2932
+ userId: USER_A_ID,
2933
+ phone: "+14155550000",
2934
+ isVerified: true,
2935
+ id: secondSmsId,
2936
+ }),
2937
+ ] as never);
2938
+
2939
+ const readiness: UserReadiness = await onlyUser();
2940
+ const smsMethods: Array<ReadinessMethod> = readiness.methods.filter(
2941
+ (method: ReadinessMethod): boolean => {
2942
+ return method.methodType === ReadinessMethodType.SMS;
2943
+ },
2944
+ );
2945
+
2946
+ expect(smsMethods).toHaveLength(2);
2947
+ expect(
2948
+ smsMethods.map((method: ReadinessMethod): string => {
2949
+ return method.methodId.toString();
2950
+ }),
2951
+ ).toEqual([SMS_METHOD_ID.toString(), secondSmsId.toString()]);
2952
+ });
2953
+
2954
+ test("one responder's method id is never attached to another responder's mask", async () => {
2955
+ attachDirectly(USER_A_ID, USER_B_ID);
2956
+ smsFindBy.mockResolvedValue([
2957
+ smsMethod({
2958
+ userId: USER_B_ID,
2959
+ phone: RAW_SMS_PHONE,
2960
+ isVerified: true,
2961
+ id: SMS_METHOD_ID,
2962
+ }),
2963
+ ] as never);
2964
+
2965
+ const summary: ReadinessSummary = await policySummary();
2966
+ const ada: UserReadiness = summary.users.find(
2967
+ (user: UserReadiness): boolean => {
2968
+ return user.userId.toString() === USER_A_ID.toString();
2969
+ },
2970
+ )!;
2971
+ const grace: UserReadiness = summary.users.find(
2972
+ (user: UserReadiness): boolean => {
2973
+ return user.userId.toString() === USER_B_ID.toString();
2974
+ },
2975
+ )!;
2976
+
2977
+ expect(ada.methods).toEqual([]);
2978
+ expect(
2979
+ methodOfType(grace, ReadinessMethodType.SMS).methodId.toString(),
2980
+ ).toBe(SMS_METHOD_ID.toString());
2981
+ });
2982
+
2983
+ test("a row that arrives with no id at all is dropped, not emitted with a hole where its id should be", async () => {
2984
+ /*
2985
+ * Unreachable while every select asks for `_id`, and pinned anyway: the
2986
+ * alternative to dropping is an option in the rule form that cannot be
2987
+ * submitted, or a `methodId` typed optional so that every consumer has to
2988
+ * handle a case that cannot happen. Dropping errs towards reporting the
2989
+ * responder as LESS reachable than they are, which is the direction this
2990
+ * service always errs in.
2991
+ */
2992
+ const idless: UserPush = new UserPush();
2993
+ idless.userId = USER_A_ID;
2994
+ idless.isVerified = true;
2995
+
2996
+ pushFindBy.mockResolvedValue([idless] as never);
2997
+
2998
+ const readiness: UserReadiness = await onlyUser();
2999
+
3000
+ expect(readiness.methods).toEqual([]);
3001
+ expect(readiness.status).toBe(ReadinessStatus.NotReachable);
3002
+ });
3003
+ });
3004
+
3005
+ /*
3006
+ * ---------------------------------------------------------------------------
3007
+ * (H) Masking, end to end.
3008
+ *
3009
+ * The assertions here run over the SERIALIZED summary rather than the fields a
3010
+ * test author remembered to check, because a leak that arrives through a field
3011
+ * nobody listed is exactly the leak that ships.
3012
+ * ---------------------------------------------------------------------------
3013
+ */
3014
+ describe("identifier exposure", () => {
3015
+ beforeEach(() => {
3016
+ attachDirectly(USER_A_ID);
3017
+ pushFindBy.mockResolvedValue([
3018
+ pushMethod({
3019
+ userId: USER_A_ID,
3020
+ deviceName: RAW_PUSH_DEVICE,
3021
+ isVerified: true,
3022
+ }),
3023
+ ] as never);
3024
+ emailFindBy.mockResolvedValue([
3025
+ emailMethod({
3026
+ userId: USER_A_ID,
3027
+ email: RAW_NOTIFICATION_EMAIL,
3028
+ isVerified: true,
3029
+ }),
3030
+ ] as never);
3031
+ smsFindBy.mockResolvedValue([
3032
+ smsMethod({ userId: USER_A_ID, phone: RAW_SMS_PHONE, isVerified: true }),
3033
+ ] as never);
3034
+ callFindBy.mockResolvedValue([
3035
+ callMethod({
3036
+ userId: USER_A_ID,
3037
+ phone: RAW_CALL_PHONE,
3038
+ isVerified: true,
3039
+ }),
3040
+ ] as never);
3041
+ whatsAppFindBy.mockResolvedValue([
3042
+ whatsAppMethod({
3043
+ userId: USER_A_ID,
3044
+ phone: RAW_WHATSAPP_PHONE,
3045
+ isVerified: true,
3046
+ }),
3047
+ ] as never);
3048
+ telegramFindBy.mockResolvedValue([
3049
+ telegramMethod({
3050
+ userId: USER_A_ID,
3051
+ handle: RAW_TELEGRAM_HANDLE,
3052
+ isVerified: true,
3053
+ }),
3054
+ ] as never);
3055
+ webhookFindBy.mockResolvedValue([
3056
+ webhookMethod({
3057
+ userId: USER_A_ID,
3058
+ name: RAW_WEBHOOK_NAME,
3059
+ webhookUrl: RAW_WEBHOOK_URL,
3060
+ }),
3061
+ ] as never);
3062
+ });
3063
+
3064
+ test("every emitted identifier is masked, one shape at a time", async () => {
3065
+ const readiness: UserReadiness = await onlyUser();
3066
+ const masked: Map<string, string> = new Map<string, string>(
3067
+ readiness.methods.map((method: ReadinessMethod): [string, string] => {
3068
+ return [method.methodType, method.maskedIdentifier];
3069
+ }),
3070
+ );
3071
+
3072
+ expect(masked.get(ReadinessMethodType.Push)).toBe(`Ad${IDENTIFIER_MASK}`);
3073
+ expect(masked.get(ReadinessMethodType.Email)).toBe(
3074
+ `a${IDENTIFIER_MASK}@analytical-engine.example`,
3075
+ );
3076
+ expect(masked.get(ReadinessMethodType.SMS)).toBe(
3077
+ `+1 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4821`,
3078
+ );
3079
+ expect(masked.get(ReadinessMethodType.Call)).toBe(
3080
+ `+44 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 8750`,
3081
+ );
3082
+ /*
3083
+ * "+6", not "+61" - see the heuristic test above. An eleven-digit
3084
+ * Australian number splits one digit early, which hides more than it
3085
+ * promises rather than less.
3086
+ */
3087
+ expect(masked.get(ReadinessMethodType.WhatsApp)).toBe(
3088
+ `+6 ${IDENTIFIER_MASK} ${IDENTIFIER_MASK} 4000`,
3089
+ );
3090
+ expect(masked.get(ReadinessMethodType.Telegram)).toBe(
3091
+ `@ad${IDENTIFIER_MASK}`,
3092
+ );
3093
+ expect(masked.get(ReadinessMethodType.Webhook)).toBe(
3094
+ `Pa${IDENTIFIER_MASK}`,
3095
+ );
3096
+ });
3097
+
3098
+ test("a method carries FOUR fields and no fifth - the id is the ONLY thing beside the mask", async () => {
3099
+ const readiness: UserReadiness = await onlyUser();
3100
+
3101
+ expect(readiness.methods).toHaveLength(7);
3102
+
3103
+ /*
3104
+ * An exact key set, not a "does not contain the url" check, and this is the
3105
+ * strongest assertion in the file. Every field on a ReadinessMethod ships to
3106
+ * every administrator of the project, so the question is never "is this
3107
+ * particular new field safe" but "did anyone add a field at all" - the raw
3108
+ * phone number, the webhook bearer url, the push device token, the telegram
3109
+ * chat id and the verification code all live one property away on the row
3110
+ * these are built from. `methodId` was added deliberately, because a foreign
3111
+ * key addresses nothing on its own; a fifth field has to argue its way
3112
+ * through this test.
3113
+ */
3114
+ for (const method of readiness.methods) {
3115
+ expect(Object.keys(method).sort()).toEqual([
3116
+ "isVerified",
3117
+ "maskedIdentifier",
3118
+ "methodId",
3119
+ "methodType",
3120
+ ]);
3121
+ }
3122
+ });
3123
+
3124
+ test("NO raw identifier survives anywhere in the serialized summary", async () => {
3125
+ const summary: ReadinessSummary = await policySummary();
3126
+ const serialized: string = JSON.stringify(summary);
3127
+
3128
+ /*
3129
+ * The ids ARE on the wire now - that is what makes an admin dropdown
3130
+ * possible without a cross-user read - so this assertion is the one that
3131
+ * says the id is all that was added. It runs over the serialized summary
3132
+ * rather than over the fields listed above precisely because a leak that
3133
+ * arrives through a field nobody listed is the leak that ships.
3134
+ */
3135
+ expect(serialized).toContain("methodId");
3136
+
3137
+ /*
3138
+ * Whole values first, then the "revealing middle" of each - the part that
3139
+ * no legitimate mask can keep - so a partial leak through some future field
3140
+ * cannot pass by having merely reordered the string.
3141
+ */
3142
+ for (const raw of [
3143
+ RAW_NOTIFICATION_EMAIL,
3144
+ RAW_SMS_PHONE,
3145
+ RAW_CALL_PHONE,
3146
+ RAW_WHATSAPP_PHONE,
3147
+ RAW_TELEGRAM_HANDLE,
3148
+ RAW_WEBHOOK_NAME,
3149
+ RAW_WEBHOOK_URL,
3150
+ RAW_PUSH_DEVICE,
3151
+ ]) {
3152
+ expect(serialized).not.toContain(raw);
3153
+ }
3154
+
3155
+ for (const fragment of [
3156
+ "ada.lovelace",
3157
+ "4155554",
3158
+ "2071838",
3159
+ "6129374",
3160
+ "night_pager",
3161
+ "Incident Bridge",
3162
+ "SUPERSECRETTOKEN",
3163
+ "iPhone",
3164
+ ]) {
3165
+ expect(serialized).not.toContain(fragment);
3166
+ }
3167
+ });
3168
+
3169
+ test("the LOGIN email is deliberately NOT masked - it is already admin-readable everywhere else", async () => {
3170
+ const summary: ReadinessSummary = await policySummary();
3171
+
3172
+ expect(summary.users[0]!.userEmail).toBe(USER_A_LOGIN_EMAIL);
3173
+ expect(JSON.stringify(summary)).toContain(USER_A_LOGIN_EMAIL);
3174
+ });
3175
+
3176
+ test("the webhook read never asks for the bearer url", async () => {
3177
+ await policySummary();
3178
+
3179
+ const select: Record<string, unknown> = firstCall(webhookFindBy).select!;
3180
+
3181
+ /*
3182
+ * UserWebhook.webhookUrl is a bearer credential - anyone holding a
3183
+ * Slack/Discord/Teams hook url can post as the integration - so it must
3184
+ * never leave the server on this path. Asserting on the exact select rather
3185
+ * than on absence alone means a widened select is a failure even if the new
3186
+ * column is not itself the url.
3187
+ */
3188
+ expect(select).toEqual({ _id: true, userId: true, name: true });
3189
+ expect(Object.keys(select)).not.toContain("webhookUrl");
3190
+ expect(Object.keys(select)).not.toContain("url");
3191
+ expect(Object.keys(select)).not.toContain("secret");
3192
+ });
3193
+
3194
+ test("the telegram read never asks for the chat id, which is the addressable target", async () => {
3195
+ await policySummary();
3196
+
3197
+ const select: Record<string, unknown> = firstCall(telegramFindBy).select!;
3198
+
3199
+ expect(Object.keys(select)).not.toContain("telegramChatId");
3200
+ expect(select["telegramUserHandle"]).toBe(true);
3201
+ });
3202
+
3203
+ test("a method with no identifier at all still reports the bare mask, not an empty cell", async () => {
3204
+ pushFindBy.mockResolvedValue([
3205
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
3206
+ ] as never);
3207
+
3208
+ const readiness: UserReadiness = await onlyUser();
3209
+ const push: ReadinessMethod = readiness.methods.find(
3210
+ (method: ReadinessMethod): boolean => {
3211
+ return method.methodType === ReadinessMethodType.Push;
3212
+ },
3213
+ )!;
3214
+
3215
+ expect(push.maskedIdentifier).toBe(IDENTIFIER_MASK);
3216
+ });
3217
+ });
3218
+
3219
+ /*
3220
+ * ---------------------------------------------------------------------------
3221
+ * (I) Batching and paging - the N+1 and truncation regression guards.
3222
+ * ---------------------------------------------------------------------------
3223
+ */
3224
+ describe("batching and paging", () => {
3225
+ function manyUsers(count: number): Array<ObjectID> {
3226
+ const ids: Array<ObjectID> = [];
3227
+
3228
+ for (let index: number = 0; index < count; index++) {
3229
+ ids.push(
3230
+ new ObjectID(
3231
+ `c0ffee00-0000-4000-8000-${String(index).padStart(12, "0")}`,
3232
+ ),
3233
+ );
3234
+ }
3235
+
3236
+ return ids;
3237
+ }
3238
+
3239
+ test("the query count does not grow with the number of responders OR severities", async () => {
3240
+ attachDirectly(USER_A_ID, USER_B_ID);
3241
+ setSeverities({
3242
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
3243
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
3244
+ });
3245
+
3246
+ const small: ReadinessSummary = await policySummary();
3247
+ const smallQueryCount: number = totalQueryCount();
3248
+
3249
+ expect(small.users).toHaveLength(2);
3250
+
3251
+ jest.clearAllMocks();
3252
+ OnCallReadinessService.clearCache();
3253
+
3254
+ const ids: Array<ObjectID> = manyUsers(12);
3255
+ userDirectory = ids.map((id: ObjectID, index: number): User => {
3256
+ return makeUser(
3257
+ id,
3258
+ `Responder ${index}`,
3259
+ `responder${index}@corp.example.com`,
3260
+ );
3261
+ });
3262
+ attachDirectly(...ids);
3263
+ setSeverities({
3264
+ incident: [
3265
+ incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
3266
+ incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
3267
+ incidentSeverity(
3268
+ new ObjectID("d0d0d0d0-0000-4000-8000-000000000001"),
3269
+ "Sev3",
3270
+ ),
3271
+ incidentSeverity(
3272
+ new ObjectID("d0d0d0d0-0000-4000-8000-000000000002"),
3273
+ "Sev4",
3274
+ ),
3275
+ ],
3276
+ alert: [
3277
+ alertSeverity(ALERT_SEVERITY_1_ID, "Warning"),
3278
+ alertSeverity(ALERT_SEVERITY_2_ID, "Critical"),
3279
+ alertSeverity(
3280
+ new ObjectID("e0e0e0e0-0000-4000-8000-000000000001"),
3281
+ "Info",
3282
+ ),
3283
+ ],
3284
+ });
3285
+
3286
+ const large: ReadinessSummary = await policySummary();
3287
+
3288
+ expect(large.users).toHaveLength(12);
3289
+ expect(large.users[0]!.coverage).toHaveLength(14);
3290
+
3291
+ /*
3292
+ * Six times the responders and seven times the severities, and not one
3293
+ * extra round trip. TeamComplianceService ran a findBy per severity per
3294
+ * user, which is the shape this asserts can never come back.
3295
+ */
3296
+ expect(totalQueryCount()).toBe(smallQueryCount);
3297
+ });
3298
+
3299
+ test("no service is asked anything more than once for a single summary", async () => {
3300
+ attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
3301
+ escalationTeamFindBy.mockResolvedValue([
3302
+ escalationTeamRow(TEAM_ID),
3303
+ escalationTeamRow(OTHER_TEAM_ID),
3304
+ ] as never);
3305
+ teamMemberRows = [teamMemberRow(USER_A_ID), teamMemberRow(USER_B_ID)];
3306
+ escalationScheduleFindBy.mockResolvedValue([
3307
+ escalationScheduleRow(SCHEDULE_ID),
3308
+ ] as never);
3309
+ scheduleLayerUserFindBy.mockResolvedValue([
3310
+ layerUserRow(USER_C_ID),
3311
+ ] as never);
3312
+ setSeverities({
3313
+ incident: [
3314
+ incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1"),
3315
+ incidentSeverity(INCIDENT_SEVERITY_2_ID, "Sev2"),
3316
+ ],
3317
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
3318
+ });
3319
+
3320
+ await policySummary();
3321
+
3322
+ for (const spy of everySpy()) {
3323
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(1);
3324
+ }
3325
+ });
3326
+
3327
+ test("the per-user reads are ONE query each over the whole responder set", async () => {
3328
+ const ids: Array<ObjectID> = manyUsers(5);
3329
+ userDirectory = ids.map((id: ObjectID, index: number): User => {
3330
+ return makeUser(
3331
+ id,
3332
+ `Responder ${index}`,
3333
+ `responder${index}@corp.example.com`,
3334
+ );
3335
+ });
3336
+ attachDirectly(...ids);
3337
+
3338
+ await policySummary();
3339
+
3340
+ const expected: Array<string> = ids.map((id: ObjectID): string => {
3341
+ return id.toString();
3342
+ });
3343
+
3344
+ for (const spy of [
3345
+ userFindBy,
3346
+ pushFindBy,
3347
+ emailFindBy,
3348
+ smsFindBy,
3349
+ callFindBy,
3350
+ whatsAppFindBy,
3351
+ telegramFindBy,
3352
+ webhookFindBy,
3353
+ notificationRuleFindBy,
3354
+ ]) {
3355
+ expect(spy).toHaveBeenCalledTimes(1);
3356
+ }
3357
+
3358
+ expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual(expected);
3359
+
3360
+ for (const spy of [
3361
+ pushFindBy,
3362
+ emailFindBy,
3363
+ smsFindBy,
3364
+ callFindBy,
3365
+ whatsAppFindBy,
3366
+ telegramFindBy,
3367
+ webhookFindBy,
3368
+ notificationRuleFindBy,
3369
+ ]) {
3370
+ expect(includedIds(firstCall(spy).query["userId"])).toEqual(expected);
3371
+ }
3372
+ });
3373
+
3374
+ test("every read asks for a full page at a time, never a bare hundred", async () => {
3375
+ attachDirectly(USER_A_ID);
3376
+ escalationTeamFindBy.mockResolvedValue([
3377
+ escalationTeamRow(TEAM_ID),
3378
+ ] as never);
3379
+ teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
3380
+ escalationScheduleFindBy.mockResolvedValue([
3381
+ escalationScheduleRow(SCHEDULE_ID),
3382
+ ] as never);
3383
+ scheduleLayerUserFindBy.mockResolvedValue([
3384
+ layerUserRow(USER_A_ID, SCHEDULE_ID),
3385
+ ] as never);
3386
+ setSeverities({
3387
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
3388
+ alert: [alertSeverity(ALERT_SEVERITY_1_ID, "Warning")],
3389
+ });
3390
+
3391
+ await policySummary();
3392
+
3393
+ const calls: Array<FindByCall> = everyFindByCall();
3394
+
3395
+ // A guard that asserts over an empty list guards nothing.
3396
+ expect(calls.length).toBeGreaterThanOrEqual(15);
3397
+
3398
+ for (const call of calls) {
3399
+ /*
3400
+ * LIMIT_PER_PROJECT is now a PAGE SIZE rather than a cap: every one of
3401
+ * these reads keeps going until a short page comes back (see the paging
3402
+ * tests below). The assertion stays because the page size still has to be
3403
+ * the shared constant rather than a literal, and because a `limit: 100`
3404
+ * reappearing anywhere would be the original truncation defect returning.
3405
+ */
3406
+ expect(call.limit).toBe(LIMIT_PER_PROJECT);
3407
+ expect(call.limit).not.toBe(100);
3408
+ expect(call.skip).toBe(0);
3409
+ expect(call.props?.isRoot).toBe(true);
3410
+ }
3411
+
3412
+ /*
3413
+ * And the constant is the shared one rather than a literal that happens to
3414
+ * agree with it today.
3415
+ */
3416
+ expect(LIMIT_PER_PROJECT).toBe(10000);
3417
+ });
3418
+
3419
+ test("severities are read in display order so the coverage grid matches the severity screen", async () => {
3420
+ attachDirectly(USER_A_ID);
3421
+ setSeverities({
3422
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
3423
+ });
3424
+
3425
+ await policySummary();
3426
+
3427
+ /*
3428
+ * Both severity lists are read ascending by `order`, which is the same
3429
+ * order the severity settings screen shows. The coverage grid is a table an
3430
+ * admin fixes cell by cell, so its columns have to line up with the screen
3431
+ * they go to afterwards - a grid sorted by uuid would be unusable without
3432
+ * being visibly wrong.
3433
+ *
3434
+ * `_id` comes after it as a tiebreak rather than instead of it: two
3435
+ * severities with the same `order` would otherwise be free to swap places
3436
+ * between pages, and a read that pages needs a TOTAL order or it can return
3437
+ * one row twice and skip another.
3438
+ */
3439
+ for (const spy of [incidentSeverityFindBy, alertSeverityFindBy]) {
3440
+ const call: FindByCall = firstCall(spy);
3441
+
3442
+ expect(call.query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
3443
+ expect(call.sort).toEqual({
3444
+ order: SortOrder.Ascending,
3445
+ _id: SortOrder.Ascending,
3446
+ });
3447
+ }
3448
+ });
3449
+
3450
+ test("EVERY read is sorted with _id last, because OFFSET paging over an unstable order loses rows", async () => {
3451
+ attachDirectly(USER_A_ID);
3452
+ escalationTeamFindBy.mockResolvedValue([
3453
+ escalationTeamRow(TEAM_ID),
3454
+ ] as never);
3455
+ teamMemberRows = [teamMemberRow(USER_A_ID, TEAM_ID)];
3456
+ escalationScheduleFindBy.mockResolvedValue([
3457
+ escalationScheduleRow(SCHEDULE_ID),
3458
+ ] as never);
3459
+ scheduleLayerUserFindBy.mockResolvedValue([
3460
+ layerUserRow(USER_A_ID, SCHEDULE_ID),
3461
+ ] as never);
3462
+ setSeverities({
3463
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
3464
+ });
3465
+
3466
+ await policySummary();
3467
+
3468
+ const calls: Array<FindByCall> = everyFindByCall();
3469
+
3470
+ expect(calls.length).toBeGreaterThanOrEqual(15);
3471
+
3472
+ for (const call of calls) {
3473
+ /*
3474
+ * The database layer's own default is `createdAt DESC`, which is NOT
3475
+ * unique - a migration that wrote a project's default notification rules
3476
+ * in one transaction gives thousands of rows the same createdAt, and
3477
+ * paging through them by OFFSET would then be free to return one row on
3478
+ * two pages and another on none. Every read here ends its sort with the
3479
+ * primary key for exactly that reason.
3480
+ */
3481
+ const sortKeys: Array<string> = Object.keys(call.sort || {});
3482
+
3483
+ expect(sortKeys[sortKeys.length - 1]).toBe("_id");
3484
+ expect(call.sort?.["_id"]).toBe(SortOrder.Ascending);
3485
+ }
3486
+ });
3487
+
3488
+ test("every method read is scoped to the project as well as the user", async () => {
3489
+ attachDirectly(USER_A_ID);
3490
+
3491
+ await policySummary();
3492
+
3493
+ for (const spy of [
3494
+ pushFindBy,
3495
+ emailFindBy,
3496
+ smsFindBy,
3497
+ callFindBy,
3498
+ whatsAppFindBy,
3499
+ telegramFindBy,
3500
+ webhookFindBy,
3501
+ notificationRuleFindBy,
3502
+ ]) {
3503
+ expect(firstCall(spy).query["projectId"]?.toString()).toBe(
3504
+ PROJECT_ID.toString(),
3505
+ );
3506
+ }
3507
+ });
3508
+
3509
+ /*
3510
+ * -------------------------------------------------------------------------
3511
+ * Paging, which is the whole of the truncation fix.
3512
+ *
3513
+ * A single capped read is not a performance choice, it is a correctness one,
3514
+ * and it fails in the worst available direction: the rows past the cap are
3515
+ * not reported as missing, they are reported as ABSENT. A responder dropped
3516
+ * that way appears in no count, no list and no "needs attention" section,
3517
+ * which is byte-for-byte identical to a responder who is fine.
3518
+ *
3519
+ * These tests are built around a genuinely full page - LIMIT_PER_PROJECT rows
3520
+ * back from one call - because that is the only signal a pager has that there
3521
+ * might be more, and everything downstream hangs off it.
3522
+ * -------------------------------------------------------------------------
3523
+ */
3524
+ function fullPageOfDirectResponders(
3525
+ userId: ObjectID,
3526
+ ): Array<OnCallDutyPolicyEscalationRuleUser> {
3527
+ const rows: Array<OnCallDutyPolicyEscalationRuleUser> = [];
3528
+
3529
+ for (let index: number = 0; index < LIMIT_PER_PROJECT; index++) {
3530
+ rows.push(escalationUserRow(userId));
3531
+ }
3532
+
3533
+ return rows;
3534
+ }
3535
+
3536
+ test("a responder who lands on the SECOND page is still a responder", async () => {
3537
+ escalationUserFindBy
3538
+ .mockResolvedValueOnce(fullPageOfDirectResponders(USER_A_ID) as never)
3539
+ .mockResolvedValueOnce([escalationUserRow(USER_B_ID)] as never);
3540
+
3541
+ const summary: ReadinessSummary = await policySummary();
3542
+
3543
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
3544
+ expect(callAt(escalationUserFindBy, 0).skip).toBe(0);
3545
+ expect(callAt(escalationUserFindBy, 1).skip).toBe(LIMIT_PER_PROJECT);
3546
+
3547
+ /*
3548
+ * Grace is the responder the old single capped read lost. She has no
3549
+ * notification method at all, so she is exactly the person the table exists
3550
+ * to surface - and the version of this service that stopped at one page
3551
+ * would have counted a project containing her as entirely healthy.
3552
+ */
3553
+ expect(
3554
+ summary.users.map((user: UserReadiness): string => {
3555
+ return user.userId.toString();
3556
+ }),
3557
+ ).toEqual([USER_A_ID.toString(), USER_B_ID.toString()]);
3558
+ expect(summary.notReachableCount).toBe(2);
3559
+ expect(summary.isTruncated).toBe(false);
3560
+ }, 60000);
3561
+
3562
+ test("a notification rule that only exists on the SECOND page still counts as coverage", async () => {
3563
+ attachDirectly(USER_A_ID);
3564
+ pushFindBy.mockResolvedValue([
3565
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
3566
+ ] as never);
3567
+ setSeverities({
3568
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
3569
+ });
3570
+
3571
+ /*
3572
+ * This is the read that overflows first in real life: rule rows grow as
3573
+ * users x rule types x severities x methods, so a few thousand responders
3574
+ * is already hundreds of thousands of rows. Under the old single read the
3575
+ * users whose rules sorted late were scored against ZERO rules and reported
3576
+ * as gaps - a table full of false amber, which teaches admins to ignore
3577
+ * amber.
3578
+ */
3579
+ const filler: Array<UserNotificationRule> = [];
3580
+
3581
+ for (let index: number = 0; index < LIMIT_PER_PROJECT; index++) {
3582
+ filler.push(
3583
+ notificationRule({
3584
+ userId: USER_A_ID,
3585
+ ruleType: NotificationRuleType.WHEN_USER_GOES_ON_CALL,
3586
+ }),
3587
+ );
3588
+ }
3589
+
3590
+ notificationRuleFindBy
3591
+ .mockResolvedValueOnce(filler as never)
3592
+ .mockResolvedValueOnce([
3593
+ notificationRule({
3594
+ userId: USER_A_ID,
3595
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
3596
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
3597
+ }),
3598
+ notificationRule({
3599
+ userId: USER_A_ID,
3600
+ ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
3601
+ incidentSeverityId: INCIDENT_SEVERITY_1_ID,
3602
+ }),
3603
+ ] as never);
3604
+
3605
+ const readiness: UserReadiness = await onlyUser();
3606
+
3607
+ expect(notificationRuleFindBy).toHaveBeenCalledTimes(2);
3608
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
3609
+ expect(readiness.reasons).toEqual([]);
3610
+ }, 60000);
3611
+
3612
+ test("a read that never runs out is REPORTED as truncated, not quietly cut short", async () => {
3613
+ const loggedErrors: Array<string> = [];
3614
+
3615
+ jest.spyOn(logger, "error").mockImplementation(((
3616
+ message: unknown,
3617
+ ): void => {
3618
+ loggedErrors.push(String(message));
3619
+ }) as never);
3620
+
3621
+ /*
3622
+ * A read that returns a full page forever - a paging bug, or a table growing
3623
+ * faster than it can be read.
3624
+ */
3625
+ escalationUserFindBy.mockResolvedValue(
3626
+ fullPageOfDirectResponders(USER_A_ID) as never,
3627
+ );
3628
+
3629
+ const summary: ReadinessSummary = await policySummary();
3630
+
3631
+ /*
3632
+ * Two things have to be true at once, and neither is sufficient alone. It
3633
+ * has to STOP - an unbounded loop holding a connection is its own outage -
3634
+ * and it has to SAY SO, both to the caller (isTruncated, which the UI can
3635
+ * render) and to the operator (a logged error, which is where somebody
3636
+ * works out why). A ceiling that only logs is invisible to the admin
3637
+ * reading the table; a ceiling that only flags is invisible to whoever has
3638
+ * to fix it.
3639
+ */
3640
+ expect(escalationUserFindBy.mock.calls.length).toBeGreaterThan(1);
3641
+ expect(summary.isTruncated).toBe(true);
3642
+ expect(
3643
+ loggedErrors.some((message: string): boolean => {
3644
+ return message.includes("INCOMPLETE");
3645
+ }),
3646
+ ).toBe(true);
3647
+
3648
+ // And it still returns everything it did manage to read.
3649
+ expect(summary.users).toHaveLength(1);
3650
+ }, 120000);
3651
+
3652
+ test("an ordinary summary is not flagged as truncated - the flag has to MEAN something", async () => {
3653
+ attachDirectly(USER_A_ID);
3654
+
3655
+ expect((await policySummary()).isTruncated).toBe(false);
3656
+ });
3657
+ });
3658
+
3659
+ /*
3660
+ * ---------------------------------------------------------------------------
3661
+ * (J) getReadinessForUser, including its cross-project guard.
3662
+ * ---------------------------------------------------------------------------
3663
+ */
3664
+ describe("getReadinessForUser", () => {
3665
+ test("answers for a user who is on no policy at all - which is when the mistake is cheap to fix", async () => {
3666
+ pushFindBy.mockResolvedValue([
3667
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
3668
+ ] as never);
3669
+
3670
+ const readiness: UserReadiness =
3671
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3672
+
3673
+ expect(readiness.reachedVia).toEqual([]);
3674
+ expect(readiness.status).toBe(ReadinessStatus.Ready);
3675
+ });
3676
+
3677
+ test("reachedVia says WHY they are on call, resolved from their own memberships", async () => {
3678
+ /*
3679
+ * The user is in TEAM_ID (their membership row says so) and TEAM_ID is
3680
+ * attached to an escalation rule, so they are reached through the team.
3681
+ * Note what does NOT happen to establish that: the project's teams are
3682
+ * never expanded into their full rosters. The question asked is "is any
3683
+ * team THIS user is in attached to a rule", which is answered by two reads
3684
+ * keyed on this user, not by resolving the whole project.
3685
+ */
3686
+ escalationTeamFindBy.mockResolvedValue([
3687
+ escalationTeamRow(TEAM_ID),
3688
+ ] as never);
3689
+
3690
+ const readiness: UserReadiness =
3691
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3692
+
3693
+ expect(readiness.reachedVia).toEqual([ResponderSource.Team]);
3694
+ expect(
3695
+ includedIds(firstCall(escalationTeamFindBy).query["teamId"]),
3696
+ ).toEqual([TEAM_ID.toString()]);
3697
+ });
3698
+
3699
+ test("a team the user is NOT in does not make them a responder", async () => {
3700
+ /*
3701
+ * The inverse of the test above, and the one that would catch a targeted
3702
+ * resolution that forgot to intersect: OTHER_TEAM_ID is attached to an
3703
+ * escalation rule, but this user is only in TEAM_ID.
3704
+ */
3705
+ escalationTeamFindBy.mockResolvedValue([
3706
+ escalationTeamRow(OTHER_TEAM_ID),
3707
+ ] as never);
3708
+
3709
+ const readiness: UserReadiness =
3710
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3711
+
3712
+ expect(readiness.reachedVia).toEqual([]);
3713
+ });
3714
+
3715
+ test("a schedule the user is on counts only while that schedule is attached to a rule", async () => {
3716
+ scheduleLayerUserFindBy.mockResolvedValue([
3717
+ layerUserRow(USER_A_ID, SCHEDULE_ID),
3718
+ ] as never);
3719
+
3720
+ const unattached: UserReadiness =
3721
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3722
+
3723
+ expect(unattached.reachedVia).toEqual([]);
3724
+
3725
+ OnCallReadinessService.clearCache();
3726
+ escalationScheduleFindBy.mockResolvedValue([
3727
+ escalationScheduleRow(SCHEDULE_ID),
3728
+ ] as never);
3729
+
3730
+ const attached: UserReadiness =
3731
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3732
+
3733
+ expect(attached.reachedVia).toEqual([ResponderSource.Schedule]);
3734
+ });
3735
+
3736
+ test("refuses a user who is not a member of the project - User is a GLOBAL model", async () => {
3737
+ membershipRows = [];
3738
+
3739
+ await expect(
3740
+ OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID),
3741
+ ).rejects.toThrow("User is not a member of this project");
3742
+
3743
+ /*
3744
+ * And it refuses BEFORE reading anything else, so a caller probing arbitrary
3745
+ * user ids learns nothing - not even a name.
3746
+ */
3747
+ expect(userFindBy).not.toHaveBeenCalled();
3748
+ expect(escalationUserFindBy).not.toHaveBeenCalled();
3749
+ expect(projectFindOneById).not.toHaveBeenCalled();
3750
+ });
3751
+
3752
+ test("the membership check is scoped to the project AND to exactly the users asked about", async () => {
3753
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3754
+
3755
+ const call: FindByCall = firstCall(teamMemberFindBy);
3756
+
3757
+ expect(call.query["projectId"]?.toString()).toBe(PROJECT_ID.toString());
3758
+ expect(includedIds(call.query["userId"])).toEqual([USER_A_ID.toString()]);
3759
+
3760
+ /*
3761
+ * It reads teamId as well as userId, because that one read is doing two
3762
+ * jobs: it is the cross-project guard AND the input to the Team source.
3763
+ * Dropping teamId from the select would silently cost a second query per
3764
+ * call, which is the N+1 coming back in a smaller coat.
3765
+ */
3766
+ expect(call.select?.["teamId"]).toBe(true);
3767
+ expect(call.select?.["userId"]).toBe(true);
3768
+ });
3769
+
3770
+ test("a member with no User row is an error rather than a blank card", async () => {
3771
+ userDirectory = [];
3772
+
3773
+ await expect(
3774
+ OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID),
3775
+ ).rejects.toThrow("User not found");
3776
+ });
3777
+
3778
+ test("answers only about the user asked for, even when the project has other responders", async () => {
3779
+ attachDirectly(USER_A_ID, USER_B_ID, USER_C_ID);
3780
+
3781
+ const readiness: UserReadiness =
3782
+ await OnCallReadinessService.getReadinessForUser(USER_B_ID, PROJECT_ID);
3783
+
3784
+ expect(readiness.userId.toString()).toBe(USER_B_ID.toString());
3785
+ expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
3786
+ USER_B_ID.toString(),
3787
+ ]);
3788
+ });
3789
+
3790
+ test("does NOT resolve the whole project's responder set just to fill in reachedVia", async () => {
3791
+ escalationTeamFindBy.mockResolvedValue([
3792
+ escalationTeamRow(TEAM_ID),
3793
+ ] as never);
3794
+ teamMemberRows = [
3795
+ teamMemberRow(USER_A_ID, TEAM_ID),
3796
+ teamMemberRow(USER_B_ID, TEAM_ID),
3797
+ teamMemberRow(USER_C_ID, TEAM_ID),
3798
+ ];
3799
+
3800
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
3801
+
3802
+ /*
3803
+ * This is the amplified N+1 in one assertion. The old shape asked "who does
3804
+ * this project page?" - reading every escalation rule, expanding every
3805
+ * attached team into its FULL roster and every schedule into its full layer
3806
+ * list - and then looked up one user in the answer. On a project of any
3807
+ * size that is work proportional to the project, repeated once per user, to
3808
+ * produce four booleans.
3809
+ *
3810
+ * Every read below is keyed on the single user instead, and the roster
3811
+ * expansion (a TeamMember read by teamId) never happens at all.
3812
+ */
3813
+ for (const spy of [escalationUserFindBy, scheduleLayerUserFindBy]) {
3814
+ expect(includedIds(firstCall(spy).query["userId"])).toEqual([
3815
+ USER_A_ID.toString(),
3816
+ ]);
3817
+ }
3818
+
3819
+ expect(
3820
+ includedIds(firstCall(overrideFindBy).query["routeAlertsToUserId"]),
3821
+ ).toEqual([USER_A_ID.toString()]);
3822
+
3823
+ const rosterExpansions: Array<FindByCall> = teamMemberFindBy.mock.calls
3824
+ .map((call: Array<unknown>): FindByCall => {
3825
+ return call[0] as FindByCall;
3826
+ })
3827
+ .filter((call: FindByCall): boolean => {
3828
+ return Boolean(call.query["teamId"]);
3829
+ });
3830
+
3831
+ expect(rosterExpansions).toEqual([]);
3832
+ });
3833
+ });
3834
+
3835
+ /*
3836
+ * ---------------------------------------------------------------------------
3837
+ * (J2) getReadinessForUsers - the batch entry point.
3838
+ *
3839
+ * This exists so that a list-shaped caller (a team roster, a responder table, a
3840
+ * row of chips) has something to call that is not `Promise.all(ids.map(...))`.
3841
+ * That shape was not a small inefficiency: each call resolved the ENTIRE
3842
+ * project's responder set to compute one user's reachedVia, so a forty-member
3843
+ * team issued several hundred queries, and one rejection - a member removed
3844
+ * between the two reads is enough - threw away the whole page.
3845
+ *
3846
+ * The property being pinned is CONSTANT COST in the size of the input set. Not
3847
+ * "fewer queries", not "faster": the same number, for one user and for five
3848
+ * hundred, because every read is one Includes over the whole set.
3849
+ * ---------------------------------------------------------------------------
3850
+ */
3851
+ describe("getReadinessForUsers", () => {
3852
+ function seedMembers(count: number): Array<ObjectID> {
3853
+ const ids: Array<ObjectID> = [];
3854
+
3855
+ for (let index: number = 0; index < count; index++) {
3856
+ ids.push(
3857
+ new ObjectID(
3858
+ `beef0000-0000-4000-8000-${String(index).padStart(12, "0")}`,
3859
+ ),
3860
+ );
3861
+ }
3862
+
3863
+ userDirectory = ids.map((id: ObjectID, index: number): User => {
3864
+ return makeUser(
3865
+ id,
3866
+ `Responder ${index}`,
3867
+ `responder${index}@corp.example.com`,
3868
+ );
3869
+ });
3870
+ membershipRows = ids.map((id: ObjectID): TeamMember => {
3871
+ return teamMemberRow(id, TEAM_ID);
3872
+ });
3873
+
3874
+ return ids;
3875
+ }
3876
+
3877
+ test("five hundred users cost exactly the same number of queries as one", async () => {
3878
+ const one: Array<ObjectID> = seedMembers(1);
3879
+
3880
+ await OnCallReadinessService.getReadinessForUsers(one, PROJECT_ID);
3881
+
3882
+ const costOfOne: number = totalQueryCount();
3883
+
3884
+ expect(costOfOne).toBeGreaterThan(0);
3885
+
3886
+ jest.clearAllMocks();
3887
+ OnCallReadinessService.clearCache();
3888
+
3889
+ const many: Array<ObjectID> = seedMembers(500);
3890
+
3891
+ const readiness: Array<UserReadiness> =
3892
+ await OnCallReadinessService.getReadinessForUsers(many, PROJECT_ID);
3893
+
3894
+ expect(readiness).toHaveLength(500);
3895
+
3896
+ /*
3897
+ * Five hundred times the users and not one extra round trip. The old
3898
+ * fan-out would have issued something like nine thousand.
3899
+ */
3900
+ expect(totalQueryCount()).toBe(costOfOne);
3901
+ }, 60000);
3902
+
3903
+ test("every read is keyed on the users asked about, and no team is ever expanded", async () => {
3904
+ const ids: Array<ObjectID> = seedMembers(3);
3905
+ const expected: Array<string> = ids.map((id: ObjectID): string => {
3906
+ return id.toString();
3907
+ });
3908
+
3909
+ await OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID);
3910
+
3911
+ expect(includedIds(firstCall(teamMemberFindBy).query["userId"])).toEqual(
3912
+ expected,
3913
+ );
3914
+ expect(
3915
+ includedIds(firstCall(escalationUserFindBy).query["userId"]),
3916
+ ).toEqual(expected);
3917
+ expect(
3918
+ includedIds(firstCall(scheduleLayerUserFindBy).query["userId"]),
3919
+ ).toEqual(expected);
3920
+ expect(
3921
+ includedIds(firstCall(overrideFindBy).query["routeAlertsToUserId"]),
3922
+ ).toEqual(expected);
3923
+ expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual(expected);
3924
+
3925
+ for (const spy of [
3926
+ pushFindBy,
3927
+ emailFindBy,
3928
+ smsFindBy,
3929
+ callFindBy,
3930
+ whatsAppFindBy,
3931
+ telegramFindBy,
3932
+ webhookFindBy,
3933
+ notificationRuleFindBy,
3934
+ ]) {
3935
+ expect(includedIds(firstCall(spy).query["userId"])).toEqual(expected);
3936
+ }
3937
+ });
3938
+
3939
+ test("a user who is not a member is OMITTED, not thrown for - one departure must not blank the page", async () => {
3940
+ const ids: Array<ObjectID> = seedMembers(3);
3941
+
3942
+ /*
3943
+ * Exactly what happens when somebody is removed from a team between the
3944
+ * page being requested and this read running. The single-user entry point
3945
+ * throws for that user, which is right for a card about one person and
3946
+ * catastrophic for a roster: under Promise.all it discards thirty-nine
3947
+ * other people's readiness because one person left.
3948
+ */
3949
+ membershipRows = membershipRows.filter((row: TeamMember): boolean => {
3950
+ return row.userId?.toString() !== ids[1]!.toString();
3951
+ });
3952
+
3953
+ const readiness: Array<UserReadiness> =
3954
+ await OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID);
3955
+
3956
+ expect(readiness).toHaveLength(2);
3957
+ expect(
3958
+ readiness.map((one: UserReadiness): string => {
3959
+ return one.userId.toString();
3960
+ }),
3961
+ ).not.toContain(ids[1]!.toString());
3962
+ });
3963
+
3964
+ test("no member of the requested set at all is an empty list rather than an error", async () => {
3965
+ const ids: Array<ObjectID> = seedMembers(2);
3966
+ membershipRows = [];
3967
+
3968
+ await expect(
3969
+ OnCallReadinessService.getReadinessForUsers(ids, PROJECT_ID),
3970
+ ).resolves.toEqual([]);
3971
+
3972
+ // And it stops after the membership read rather than reading anything else.
3973
+ expect(userFindBy).not.toHaveBeenCalled();
3974
+ expect(escalationUserFindBy).not.toHaveBeenCalled();
3975
+ });
3976
+
3977
+ test("an empty request asks the database nothing at all", async () => {
3978
+ await expect(
3979
+ OnCallReadinessService.getReadinessForUsers([], PROJECT_ID),
3980
+ ).resolves.toEqual([]);
3981
+
3982
+ expect(totalQueryCount()).toBe(0);
3983
+ });
3984
+
3985
+ test("a duplicated user id is answered once", async () => {
3986
+ const readiness: Array<UserReadiness> =
3987
+ await OnCallReadinessService.getReadinessForUsers(
3988
+ [USER_A_ID, USER_A_ID, USER_A_ID],
3989
+ PROJECT_ID,
3990
+ );
3991
+
3992
+ expect(readiness).toHaveLength(1);
3993
+ expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
3994
+ USER_A_ID.toString(),
3995
+ ]);
3996
+ });
3997
+
3998
+ test("the batch answer for a user is the answer the single call gives", async () => {
3999
+ attachDirectly(USER_A_ID);
4000
+ pushFindBy.mockResolvedValue([
4001
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
4002
+ ] as never);
4003
+ setSeverities({
4004
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
4005
+ });
4006
+
4007
+ const batch: Array<UserReadiness> =
4008
+ await OnCallReadinessService.getReadinessForUsers(
4009
+ [USER_A_ID, USER_B_ID],
4010
+ PROJECT_ID,
4011
+ );
4012
+
4013
+ OnCallReadinessService.clearCache();
4014
+
4015
+ const single: UserReadiness =
4016
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4017
+
4018
+ const fromBatch: UserReadiness = batch.find(
4019
+ (one: UserReadiness): boolean => {
4020
+ return one.userId.toString() === USER_A_ID.toString();
4021
+ },
4022
+ )!;
4023
+
4024
+ expect(fromBatch.status).toBe(single.status);
4025
+ expect(fromBatch.reachedVia).toEqual(single.reachedVia);
4026
+ expect(fromBatch.reasons).toEqual(single.reasons);
4027
+ expect(fromBatch.coverage).toEqual(single.coverage);
4028
+ });
4029
+
4030
+ test("results are sorted most-broken-first, the same as a summary", async () => {
4031
+ pushFindBy.mockResolvedValue([
4032
+ pushMethod({ userId: USER_B_ID, isVerified: true }),
4033
+ ] as never);
4034
+
4035
+ const readiness: Array<UserReadiness> =
4036
+ await OnCallReadinessService.getReadinessForUsers(
4037
+ [USER_B_ID, USER_A_ID],
4038
+ PROJECT_ID,
4039
+ );
4040
+
4041
+ expect(
4042
+ readiness.map((one: UserReadiness): ReadinessStatus => {
4043
+ return one.status;
4044
+ }),
4045
+ ).toEqual([ReadinessStatus.NotReachable, ReadinessStatus.Ready]);
4046
+ });
4047
+
4048
+ test("a cached user costs no round trip, and only the uncached ones are read", async () => {
4049
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4050
+
4051
+ jest.clearAllMocks();
4052
+
4053
+ const readiness: Array<UserReadiness> =
4054
+ await OnCallReadinessService.getReadinessForUsers(
4055
+ [USER_A_ID, USER_B_ID],
4056
+ PROJECT_ID,
4057
+ );
4058
+
4059
+ expect(readiness).toHaveLength(2);
4060
+
4061
+ /*
4062
+ * Ada came out of the cache, so the reads that follow ask about Grace
4063
+ * alone. A batch that re-read every user whenever one of them was cold
4064
+ * would make the cache almost worthless on exactly the list-shaped callers
4065
+ * this entry point exists for.
4066
+ */
4067
+ expect(includedIds(firstCall(teamMemberFindBy).query["userId"])).toEqual([
4068
+ USER_B_ID.toString(),
4069
+ ]);
4070
+ expect(includedIds(firstCall(userFindBy).query["_id"])).toEqual([
4071
+ USER_B_ID.toString(),
4072
+ ]);
4073
+ });
4074
+
4075
+ test("a second identical batch re-queries nothing", async () => {
4076
+ await OnCallReadinessService.getReadinessForUsers(
4077
+ [USER_A_ID, USER_B_ID],
4078
+ PROJECT_ID,
4079
+ );
4080
+
4081
+ const countAfterFirst: number = totalQueryCount();
4082
+
4083
+ await OnCallReadinessService.getReadinessForUsers(
4084
+ [USER_A_ID, USER_B_ID],
4085
+ PROJECT_ID,
4086
+ );
4087
+
4088
+ expect(totalQueryCount()).toBe(countAfterFirst);
4089
+ });
4090
+ });
4091
+
4092
+ /*
4093
+ * ---------------------------------------------------------------------------
4094
+ * (J3) The fallback signal on the wire.
4095
+ *
4096
+ * Every readiness surface wants to tell an admin what a PartiallyReady
4097
+ * responder actually costs them, and the honest answer depends on one project
4098
+ * switch: with the fallback on, an uncovered severity still reaches the person
4099
+ * through their verified methods; with it off, that page is dropped. A UI that
4100
+ * cannot tell those apart says "nothing is dropped" at the exact moment
4101
+ * something is, which is worse than saying nothing at all.
4102
+ * ---------------------------------------------------------------------------
4103
+ */
4104
+ describe("isFallbackEnabled", () => {
4105
+ test("true when the project leaves the fallback on", async () => {
4106
+ attachDirectly(USER_A_ID);
4107
+
4108
+ expect((await policySummary()).isFallbackEnabled).toBe(true);
4109
+ });
4110
+
4111
+ test("false when the project disables it, and the per-user reasons agree", async () => {
4112
+ attachDirectly(USER_A_ID);
4113
+ projectFindOneById.mockResolvedValue(
4114
+ makeProject({ disableOnCallNotificationFallback: true }) as never,
4115
+ );
4116
+ pushFindBy.mockResolvedValue([
4117
+ pushMethod({ userId: USER_A_ID, isVerified: true }),
4118
+ ] as never);
4119
+ setSeverities({
4120
+ incident: [incidentSeverity(INCIDENT_SEVERITY_1_ID, "Sev1")],
4121
+ });
4122
+
4123
+ const summary: ReadinessSummary = await policySummary();
4124
+
4125
+ expect(summary.isFallbackEnabled).toBe(false);
4126
+ expect(summary.users[0]!.reasons[0]).toBe(
4127
+ "No rules for Sev1 incidents - pages are dropped because on-call fallback is disabled for this project",
4128
+ );
4129
+ });
4130
+
4131
+ test("reported even when the project has no responders yet", async () => {
4132
+ projectFindOneById.mockResolvedValue(
4133
+ makeProject({ disableOnCallNotificationFallback: true }) as never,
4134
+ );
4135
+
4136
+ const summary: ReadinessSummary = await policySummary();
4137
+
4138
+ /*
4139
+ * A project with nobody attached is precisely the project somebody is about
4140
+ * to attach somebody to, and "pages with no matching rule are dropped here"
4141
+ * is the thing they need to know before they do it - not after.
4142
+ */
4143
+ expect(summary.users).toEqual([]);
4144
+ expect(summary.isFallbackEnabled).toBe(false);
4145
+ });
4146
+
4147
+ test("the project scope reports it too, not just the policy scope", async () => {
4148
+ attachDirectly(USER_A_ID);
4149
+ projectFindOneById.mockResolvedValue(
4150
+ makeProject({ disableOnCallNotificationFallback: true }) as never,
4151
+ );
4152
+
4153
+ const summary: ReadinessSummary =
4154
+ await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
4155
+
4156
+ expect(summary.isFallbackEnabled).toBe(false);
4157
+ expect(summary.isTruncated).toBe(false);
4158
+ });
4159
+ });
4160
+
4161
+ /*
4162
+ * ---------------------------------------------------------------------------
4163
+ * (K) The policy guard.
4164
+ * ---------------------------------------------------------------------------
4165
+ */
4166
+ describe("getReadinessForPolicy guards", () => {
4167
+ test("an unknown policy is an error, not an empty and reassuring summary", async () => {
4168
+ policyFindOneById.mockResolvedValue(null as never);
4169
+
4170
+ await expect(policySummary()).rejects.toThrow(
4171
+ "On-call duty policy not found",
4172
+ );
4173
+ expect(escalationUserFindBy).not.toHaveBeenCalled();
4174
+ });
4175
+
4176
+ test("a policy from another project is refused, because every read below would return nothing", async () => {
4177
+ policyFindOneById.mockResolvedValue(makePolicy(OTHER_PROJECT_ID) as never);
4178
+
4179
+ /*
4180
+ * "0 responders, nothing wrong" is the most dangerous possible answer to
4181
+ * "is this policy safe to rely on?".
4182
+ */
4183
+ await expect(policySummary()).rejects.toThrow(
4184
+ "On-call duty policy not found",
4185
+ );
4186
+ expect(escalationUserFindBy).not.toHaveBeenCalled();
4187
+ });
4188
+
4189
+ test("a policy row with no projectId at all is refused", async () => {
4190
+ const orphan: OnCallDutyPolicy = new OnCallDutyPolicy();
4191
+ orphan.id = POLICY_ID;
4192
+ policyFindOneById.mockResolvedValue(orphan as never);
4193
+
4194
+ await expect(policySummary()).rejects.toThrow(
4195
+ "On-call duty policy not found",
4196
+ );
4197
+ });
4198
+ });
4199
+
4200
+ /*
4201
+ * ---------------------------------------------------------------------------
4202
+ * (L) The cache.
4203
+ *
4204
+ * 60 seconds, per replica, with no cross-process invalidation - so the TTL is
4205
+ * the only guarantee and the keying has to be exactly right. A summary served
4206
+ * under the wrong key is a readiness answer for the wrong scope, which is worse
4207
+ * than a slow one.
4208
+ * ---------------------------------------------------------------------------
4209
+ */
4210
+ describe("cache", () => {
4211
+ test("a second call inside the TTL re-queries nothing", async () => {
4212
+ attachDirectly(USER_A_ID);
4213
+
4214
+ const first: ReadinessSummary = await policySummary();
4215
+ const countAfterFirst: number = totalQueryCount();
4216
+ const second: ReadinessSummary = await policySummary();
4217
+
4218
+ expect(totalQueryCount()).toBe(countAfterFirst);
4219
+ expect(second).toBe(first);
4220
+ });
4221
+
4222
+ test("clearCache forces a recompute, so an admin who just fixed something sees it fixed", async () => {
4223
+ attachDirectly(USER_A_ID);
4224
+
4225
+ await policySummary();
4226
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(1);
4227
+
4228
+ await policySummary();
4229
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(1);
4230
+
4231
+ OnCallReadinessService.clearCache();
4232
+
4233
+ await policySummary();
4234
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
4235
+ });
4236
+
4237
+ test("clearCache empties the user cache as well as the summary cache", async () => {
4238
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4239
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4240
+
4241
+ expect(userFindBy).toHaveBeenCalledTimes(1);
4242
+
4243
+ OnCallReadinessService.clearCache();
4244
+
4245
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4246
+
4247
+ expect(userFindBy).toHaveBeenCalledTimes(2);
4248
+ });
4249
+
4250
+ test("the project scope and the policy scope are separate entries", async () => {
4251
+ attachDirectly(USER_A_ID);
4252
+
4253
+ await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
4254
+ await policySummary();
4255
+
4256
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
4257
+ expect(
4258
+ firstCall(escalationUserFindBy).query["onCallDutyPolicyId"],
4259
+ ).toBeUndefined();
4260
+ expect(
4261
+ callAt(escalationUserFindBy, 1).query["onCallDutyPolicyId"]?.toString(),
4262
+ ).toBe(POLICY_ID.toString());
4263
+ });
4264
+
4265
+ test("two policies in one project do not share an entry", async () => {
4266
+ attachDirectly(USER_A_ID);
4267
+
4268
+ await policySummary();
4269
+ await OnCallReadinessService.getReadinessForPolicy(
4270
+ OTHER_POLICY_ID,
4271
+ PROJECT_ID,
4272
+ );
4273
+
4274
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
4275
+ });
4276
+
4277
+ test("the project scope is keyed on the project, so another project is not served this one's answer", async () => {
4278
+ attachDirectly(USER_A_ID);
4279
+
4280
+ await OnCallReadinessService.getReadinessForProject(PROJECT_ID);
4281
+ await OnCallReadinessService.getReadinessForProject(OTHER_PROJECT_ID);
4282
+
4283
+ expect(escalationUserFindBy).toHaveBeenCalledTimes(2);
4284
+ expect(callAt(escalationUserFindBy, 1).query["projectId"]?.toString()).toBe(
4285
+ OTHER_PROJECT_ID.toString(),
4286
+ );
4287
+ });
4288
+
4289
+ test("two users do not share an entry", async () => {
4290
+ await OnCallReadinessService.getReadinessForUser(USER_A_ID, PROJECT_ID);
4291
+ await OnCallReadinessService.getReadinessForUser(USER_B_ID, PROJECT_ID);
4292
+
4293
+ expect(userFindBy).toHaveBeenCalledTimes(2);
4294
+ });
4295
+ });