@oneuptime/common 14.0.8 → 14.0.10

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 (976) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +4 -0
  9. package/Models/DatabaseModels/KubernetesAiAgent.ts +364 -0
  10. package/Models/DatabaseModels/KubernetesCluster.ts +3 -3
  11. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  12. package/Models/DatabaseModels/Project.ts +55 -5
  13. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  14. package/Models/DatabaseModels/ProxmoxResource.ts +49 -1
  15. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  16. package/Models/DatabaseModels/RunnerJob.ts +219 -0
  17. package/Models/DatabaseModels/TeamComplianceSetting.ts +143 -2
  18. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  19. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +17 -0
  20. package/Server/API/AIChatAPI.ts +6 -3
  21. package/Server/API/AIInvestigationAPI.ts +17 -10
  22. package/Server/API/AutoRemediationAPI.ts +598 -25
  23. package/Server/API/KubernetesClusterAiAccessAPI.ts +623 -21
  24. package/Server/API/MicrosoftTeamsAPI.ts +93 -14
  25. package/Server/API/ProxmoxResourceAPI.ts +244 -14
  26. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  27. package/Server/Infrastructure/GlobalCache.ts +32 -0
  28. package/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.ts +99 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.ts +137 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.ts +42 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  33. package/Server/Infrastructure/Semaphore.ts +22 -0
  34. package/Server/Middleware/TelemetryIngest.ts +51 -18
  35. package/Server/Services/AIService.ts +70 -1
  36. package/Server/Services/AlertService.ts +352 -11
  37. package/Server/Services/AlertSeverityService.ts +65 -15
  38. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  39. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  40. package/Server/Services/AutoRemediationRuleService.ts +1 -1
  41. package/Server/Services/CephClusterService.ts +109 -1
  42. package/Server/Services/DatabaseServerService.ts +116 -1
  43. package/Server/Services/DockerHostService.ts +109 -1
  44. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  45. package/Server/Services/HostService.ts +126 -4
  46. package/Server/Services/IncidentService.ts +280 -104
  47. package/Server/Services/IncidentSeverityService.ts +66 -15
  48. package/Server/Services/KubernetesAiAgentJobService.ts +185 -0
  49. package/Server/Services/KubernetesAiAgentService.ts +1634 -0
  50. package/Server/Services/KubernetesClusterAiAccessService.ts +1118 -451
  51. package/Server/Services/KubernetesClusterService.ts +129 -21
  52. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  53. package/Server/Services/PodmanHostService.ts +109 -1
  54. package/Server/Services/ProjectService.ts +32 -0
  55. package/Server/Services/ProxmoxClusterService.ts +110 -1
  56. package/Server/Services/ProxmoxResourceService.ts +322 -5
  57. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  58. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  59. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  60. package/Server/Services/RunnerJobService.ts +852 -108
  61. package/Server/Services/RunnerService.ts +1 -1
  62. package/Server/Services/ScheduledMaintenanceService.ts +737 -1
  63. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +414 -56
  64. package/Server/Services/TeamComplianceSettingService.ts +1125 -9
  65. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  66. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  67. package/Server/Services/VMwareVCenterService.ts +110 -1
  68. package/Server/Utils/AI/ClusterAccess/ClusterAccessContext.ts +67 -9
  69. package/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.ts +39 -10
  70. package/Server/Utils/AI/ClusterAccess/KubectlJobRunner.ts +14 -3
  71. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1475 -51
  72. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +1000 -43
  73. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  74. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  75. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  76. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  77. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  78. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  79. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  80. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +120 -7
  81. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  82. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  83. package/Server/Utils/AI/SRE/IncidentPostmortemRunner.ts +40 -8
  84. package/Server/Utils/AI/SRE/InvestigationEligibility.ts +13 -2
  85. package/Server/Utils/AI/SRE/InvestigationGrader.ts +1 -1
  86. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +402 -14
  87. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  88. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  89. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  90. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  91. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +133 -7
  92. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +58 -4
  93. package/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.ts +447 -0
  94. package/Server/Utils/Telemetry/ProxmoxNativePush.ts +903 -0
  95. package/Server/Utils/Telemetry/TelemetryEntity.ts +87 -27
  96. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  97. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  98. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  99. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  100. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  101. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  102. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +1073 -213
  103. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  104. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  105. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  106. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  107. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  108. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  109. package/Tests/App/AdminDashboard/RowActionPlacementOverrides.test.tsx +278 -0
  110. package/Tests/App/Dashboard/AIChatChartWidgetZoom.test.tsx +738 -0
  111. package/Tests/App/Dashboard/AIFeaturesSettingsPointers.test.tsx +147 -0
  112. package/Tests/App/Dashboard/AIInsightsKubernetesClusterPage.test.tsx +1523 -0
  113. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +463 -3
  114. package/Tests/App/Dashboard/AffectedResourcesPickerDropdownPlacement.test.tsx +958 -0
  115. package/Tests/App/Dashboard/AffectedResourcesPickerFocusLeave.test.tsx +595 -0
  116. package/Tests/App/Dashboard/AskAiAvailability.test.ts +20 -3
  117. package/Tests/App/Dashboard/AskAiDisabled.test.tsx +8 -3
  118. package/Tests/App/Dashboard/CephRateChartReload.test.tsx +1456 -0
  119. package/Tests/App/Dashboard/CephTimeRangeZoom.test.tsx +1229 -0
  120. package/Tests/App/Dashboard/ChartCardTimeRangeZoom.test.tsx +151 -0
  121. package/Tests/App/Dashboard/ChartCardZoomAffordances.test.tsx +257 -0
  122. package/Tests/App/Dashboard/ChartRefetchFrame.test.tsx +141 -0
  123. package/Tests/App/Dashboard/ChartZoomHarness.tsx +249 -0
  124. package/Tests/App/Dashboard/ChartZoomStandIn.tsx +131 -0
  125. package/Tests/App/Dashboard/CloudServerlessSlowLoads.test.tsx +628 -0
  126. package/Tests/App/Dashboard/CloudServerlessTimeRangeZoom.test.tsx +607 -0
  127. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +184 -19
  128. package/Tests/App/Dashboard/CompanionMetricsTabZoom.test.tsx +632 -0
  129. package/Tests/App/Dashboard/ContainerChartZoomStandIn.tsx +195 -0
  130. package/Tests/App/Dashboard/ContainerHostOverviewSlowLoads.test.tsx +542 -0
  131. package/Tests/App/Dashboard/ContainerHostOverviewTimeRangeZoom.test.tsx +848 -0
  132. package/Tests/App/Dashboard/ContainerMetricsTimeRangeZoom.test.tsx +561 -0
  133. package/Tests/App/Dashboard/DashboardBoardWideZoomShell.test.tsx +1147 -0
  134. package/Tests/App/Dashboard/DashboardEditPermissions.test.tsx +3 -2
  135. package/Tests/App/Dashboard/DashboardHistogramWidgetDragCursor.test.tsx +399 -0
  136. package/Tests/App/Dashboard/DashboardHistogramWidgetZoom.test.tsx +985 -0
  137. package/Tests/App/Dashboard/DashboardHistogramWidgetZoomHint.test.tsx +803 -0
  138. package/Tests/App/Dashboard/DashboardLazyPages.test.tsx +1 -0
  139. package/Tests/App/Dashboard/DashboardSettingsPreviewZoom.test.tsx +679 -0
  140. package/Tests/App/Dashboard/DashboardSloAndDataSourceChartZoom.test.tsx +881 -0
  141. package/Tests/App/Dashboard/DashboardTraceStatusWidgets.test.tsx +878 -0
  142. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  143. package/Tests/App/Dashboard/DatabaseMetricChartModalTimeRangeZoom.test.tsx +442 -0
  144. package/Tests/App/Dashboard/DatabaseOverviewTimeRangeZoom.test.tsx +831 -0
  145. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +43 -0
  146. package/Tests/App/Dashboard/DockerSwarmInsightsTimeRangeZoom.test.tsx +524 -0
  147. package/Tests/App/Dashboard/DockerSwarmRateChartRefresh.test.tsx +259 -0
  148. package/Tests/App/Dashboard/EdgeDetailPanelHistory.test.tsx +763 -0
  149. package/Tests/App/Dashboard/EdgeDetailPanelTimeRangeZoom.test.tsx +547 -0
  150. package/Tests/App/Dashboard/EdgeHistoryBucketsCases.tsx +565 -0
  151. package/Tests/App/Dashboard/EdgeHistoryBucketsKathmandu.test.tsx +73 -0
  152. package/Tests/App/Dashboard/EdgeHistoryBucketsKolkata.test.tsx +74 -0
  153. package/Tests/App/Dashboard/EdgeHistoryBucketsNewYork.test.tsx +72 -0
  154. package/Tests/App/Dashboard/EdgeHistoryBucketsUtc.test.tsx +72 -0
  155. package/Tests/App/Dashboard/EmbeddedMetricCardRefresh.test.tsx +310 -0
  156. package/Tests/App/Dashboard/EmbeddedMetricCardTimeRangeZoom.test.tsx +536 -0
  157. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +3 -1
  158. package/Tests/App/Dashboard/EscalationRuleRowActions.test.tsx +660 -0
  159. package/Tests/App/Dashboard/EventNotes.test.tsx +2 -2
  160. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +587 -13
  161. package/Tests/App/Dashboard/EventOverviewSnapshotExplorerZoom.test.tsx +1139 -0
  162. package/Tests/App/Dashboard/EventOverviewSnapshotZoomAcrossTabs.test.tsx +1057 -0
  163. package/Tests/App/Dashboard/EventOverviewTelemetrySnapshotZoom.test.tsx +757 -0
  164. package/Tests/App/Dashboard/ExceptionOccurrenceTrendZoom.test.tsx +675 -0
  165. package/Tests/App/Dashboard/ExceptionOccurrencesZoomSurvivesViewSwitch.test.tsx +405 -0
  166. package/Tests/App/Dashboard/ExceptionSignalPages.test.tsx +67 -3
  167. package/Tests/App/Dashboard/ExceptionTablesSpanStatus.test.tsx +515 -0
  168. package/Tests/App/Dashboard/ExplorerOwnZoomRegression.test.tsx +739 -0
  169. package/Tests/App/Dashboard/FlowBandwidthAxisCases.tsx +461 -0
  170. package/Tests/App/Dashboard/FlowBandwidthAxisKathmandu.test.tsx +25 -0
  171. package/Tests/App/Dashboard/FlowBandwidthAxisKolkata.test.tsx +25 -0
  172. package/Tests/App/Dashboard/FlowBandwidthAxisNewYork.test.tsx +25 -0
  173. package/Tests/App/Dashboard/FlowBandwidthAxisUtc.test.tsx +24 -0
  174. package/Tests/App/Dashboard/FlowTopTalkersZoomRefetch.test.tsx +722 -0
  175. package/Tests/App/Dashboard/FlowViewportGuard.test.tsx +2526 -0
  176. package/Tests/App/Dashboard/HostPagesSlowLoads.test.tsx +872 -0
  177. package/Tests/App/Dashboard/HostPagesTimeRangeZoom.test.tsx +1208 -0
  178. package/Tests/App/Dashboard/IncidentRootCauseTimeRangeZoom.test.tsx +358 -0
  179. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +100 -9
  180. package/Tests/App/Dashboard/InfrastructureMetricsTabTimeRangeZoom.test.tsx +485 -0
  181. package/Tests/App/Dashboard/InvestigationDrawerCompanionZoom.test.tsx +655 -0
  182. package/Tests/App/Dashboard/InvestigationDrawerZoom.test.tsx +830 -0
  183. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  184. package/Tests/App/Dashboard/InvestigationNotStarted.test.tsx +338 -2
  185. package/Tests/App/Dashboard/InvoicesPayInvoice.test.tsx +454 -16
  186. package/Tests/App/Dashboard/KubernetesClusterAiAccessSettings.test.ts +799 -819
  187. package/Tests/App/Dashboard/KubernetesClusterAiAgentOverviewCard.test.tsx +367 -0
  188. package/Tests/App/Dashboard/KubernetesClusterAiAgentStatus.test.ts +1202 -0
  189. package/Tests/App/Dashboard/KubernetesClusterAiDocumentationMarkdown.test.ts +106 -0
  190. package/Tests/App/Dashboard/KubernetesClusterAiNavigation.test.tsx +354 -0
  191. package/Tests/App/Dashboard/KubernetesClusterAiPage.test.tsx +1884 -2054
  192. package/Tests/App/Dashboard/KubernetesClusterAiPageServerParity.test.ts +478 -179
  193. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +50 -0
  194. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +2 -1
  195. package/Tests/App/Dashboard/KubernetesClusterOverviewZoom.test.tsx +1217 -0
  196. package/Tests/App/Dashboard/KubernetesClusterPagesZoom.test.tsx +731 -0
  197. package/Tests/App/Dashboard/KubernetesCostsLoadRecovery.test.tsx +869 -0
  198. package/Tests/App/Dashboard/KubernetesCostsZoom.test.tsx +1214 -0
  199. package/Tests/App/Dashboard/KubernetesCostsZoomExactHours.test.tsx +692 -0
  200. package/Tests/App/Dashboard/KubernetesNetworkThroughputReload.test.tsx +902 -0
  201. package/Tests/App/Dashboard/KubernetesNetworkZoomBucketEdges.test.tsx +571 -0
  202. package/Tests/App/Dashboard/KubernetesOverviewZoomBucketEdges.test.tsx +697 -0
  203. package/Tests/App/Dashboard/KubernetesResourceMetricsTabZoom.test.tsx +633 -0
  204. package/Tests/App/Dashboard/LogsExplorerTimeRangeZoom.test.tsx +470 -0
  205. package/Tests/App/Dashboard/LogsInsightsTimeRangeZoom.test.tsx +948 -0
  206. package/Tests/App/Dashboard/LogsViewerPinnedWindowZoom.test.tsx +486 -0
  207. package/Tests/App/Dashboard/MetricChartsBucketInspectorDoubleClick.test.tsx +1001 -0
  208. package/Tests/App/Dashboard/MetricExplorerInvestigationAutoRefresh.test.tsx +514 -0
  209. package/Tests/App/Dashboard/MetricExplorerSavedViewZoomRoundTrip.test.tsx +581 -0
  210. package/Tests/App/Dashboard/MetricExplorerTimeRangeZoom.test.tsx +1162 -0
  211. package/Tests/App/Dashboard/MetricMonitorPreviewZoomLabel.test.ts +72 -0
  212. package/Tests/App/Dashboard/MetricRowSparklineTimeRangeZoom.test.tsx +204 -0
  213. package/Tests/App/Dashboard/MetricViewFirstResultLands.test.tsx +408 -0
  214. package/Tests/App/Dashboard/MetricViewOwnZoomResetPlacement.test.tsx +952 -0
  215. package/Tests/App/Dashboard/MetricViewTimeRange.test.ts +139 -0
  216. package/Tests/App/Dashboard/MetricViewTimeRangeZoom.test.tsx +556 -0
  217. package/Tests/App/Dashboard/MonitorMetricsChartWrappersZoom.test.tsx +552 -0
  218. package/Tests/App/Dashboard/MonitorMetricsTabsTimeRangeZoom.test.tsx +740 -0
  219. package/Tests/App/Dashboard/MonitorOverviewChartsTimeRangeZoom.test.tsx +735 -0
  220. package/Tests/App/Dashboard/MonitorStepPreviewsTimeRangeZoom.test.tsx +835 -0
  221. package/Tests/App/Dashboard/NetworkDeviceTimeRangeZoom.test.tsx +1115 -0
  222. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +186 -17
  223. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +155 -46
  224. package/Tests/App/Dashboard/PasskeySettings.test.tsx +87 -2
  225. package/Tests/App/Dashboard/ProjectAISettingsPages.test.tsx +884 -0
  226. package/Tests/App/Dashboard/ProxmoxNodeRemove.test.tsx +621 -0
  227. package/Tests/App/Dashboard/ProxmoxOverviewSlowLoads.test.tsx +584 -0
  228. package/Tests/App/Dashboard/ProxmoxPagesTimeRangeZoom.test.tsx +823 -0
  229. package/Tests/App/Dashboard/ProxmoxRateChartReload.test.tsx +1253 -0
  230. package/Tests/App/Dashboard/PublicDashboardBoardWideZoom.test.tsx +605 -0
  231. package/Tests/App/Dashboard/RealRowsLineChart.tsx +250 -0
  232. package/Tests/App/Dashboard/RechartsHostsPlotCursor.test.tsx +513 -0
  233. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  234. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  235. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  236. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  237. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  238. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  239. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  240. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  241. package/Tests/App/Dashboard/RowActionPlacementOverrides.test.tsx +865 -0
  242. package/Tests/App/Dashboard/RumOverviewTimeRangeZoom.test.tsx +877 -0
  243. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  244. package/Tests/App/Dashboard/RunnerKubernetesAgentForm.test.tsx +20 -4
  245. package/Tests/App/Dashboard/RunnerSupersededByAiAgentNote.test.tsx +296 -0
  246. package/Tests/App/Dashboard/SecurityEventConnectionsTable.test.tsx +146 -36
  247. package/Tests/App/Dashboard/ServiceDatabaseSlowLoads.test.tsx +1166 -0
  248. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +1243 -104
  249. package/Tests/App/Dashboard/ServiceOverviewTimeRangeZoom.test.tsx +792 -0
  250. package/Tests/App/Dashboard/SloBudgetBurnDownZoom.test.tsx +771 -0
  251. package/Tests/App/Dashboard/SloBudgetBurnDownZoomAffordances.test.tsx +380 -0
  252. package/Tests/App/Dashboard/SloEventMetricsBarChartZoom.test.tsx +369 -0
  253. package/Tests/App/Dashboard/SloHistoryChartsTimeRangeZoom.test.tsx +694 -0
  254. package/Tests/App/Dashboard/SloHistoryChartsZoomAffordances.test.tsx +497 -0
  255. package/Tests/App/Dashboard/SloMetricsTimeRangeZoom.test.tsx +541 -0
  256. package/Tests/App/Dashboard/SlowLoadHarness.ts +123 -0
  257. package/Tests/App/Dashboard/SnapshotExplorerPrimaryZoom.test.tsx +1194 -0
  258. package/Tests/App/Dashboard/SnapshotViewerPinnedWindow.test.tsx +620 -0
  259. package/Tests/App/Dashboard/TelemetrySnapshotPanelTimeRangeZoom.test.tsx +765 -0
  260. package/Tests/App/Dashboard/TelemetrySnapshotZoom.test.tsx +516 -0
  261. package/Tests/App/Dashboard/TelemetrySnapshotZoomAcrossTabs.test.tsx +827 -0
  262. package/Tests/App/Dashboard/TimeRangeZoomPageHarness.tsx +463 -0
  263. package/Tests/App/Dashboard/TraceChartEditorStatusSplit.test.tsx +543 -0
  264. package/Tests/App/Dashboard/TraceStatusPresentation.test.tsx +967 -0
  265. package/Tests/App/Dashboard/TraceTimelineMinimapZoom.test.tsx +315 -0
  266. package/Tests/App/Dashboard/TracesAnalyticsStatusSplit.test.tsx +778 -0
  267. package/Tests/App/Dashboard/TracesAnalyticsViewZoom.test.tsx +349 -0
  268. package/Tests/App/Dashboard/TracesExplorerTimeRangeZoom.test.tsx +551 -0
  269. package/Tests/App/Dashboard/TracesStatusChartAndFacet.test.tsx +687 -0
  270. package/Tests/App/Dashboard/TracesViewerStaleResponses.test.tsx +458 -0
  271. package/Tests/App/Dashboard/UptimeStripsExcludedFromZoom.test.tsx +406 -0
  272. package/Tests/App/Dashboard/VMwareOverviewSlowLoads.test.tsx +535 -0
  273. package/Tests/App/Dashboard/VMwarePagesTimeRangeZoom.test.tsx +606 -0
  274. package/Tests/App/Dashboard/ValueWidgetSparklineStaleHover.test.tsx +1292 -0
  275. package/Tests/App/Dashboard/ValueWidgetSparklineZoom.test.tsx +765 -0
  276. package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +98 -0
  277. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  278. package/Tests/Models/DatabaseModels/KubernetesAiAgentModel.test.ts +295 -0
  279. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  280. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  281. package/Tests/Models/DatabaseModels/TeamComplianceSettingModel.test.ts +347 -0
  282. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +58 -0
  283. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +1 -1
  284. package/Tests/Server/API/AIInvestigationAPI.test.ts +123 -45
  285. package/Tests/Server/API/AutoRemediationApproveClusterRound.test.ts +1329 -0
  286. package/Tests/Server/API/AutoRemediationApproveKubectlRecheck.test.ts +25 -2
  287. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  288. package/Tests/Server/API/KubernetesClusterAiAccessAPI.test.ts +2 -0
  289. package/Tests/Server/API/KubernetesClusterAiAccessAPIAgent.test.ts +903 -0
  290. package/Tests/Server/API/MicrosoftTeamsChatsRefreshRoute.test.ts +667 -0
  291. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +294 -6
  292. package/Tests/Server/API/ProxmoxResourceAPIRemoveNode.test.ts +1932 -0
  293. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  294. package/Tests/Server/Infrastructure/GlobalCache.test.ts +195 -0
  295. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  296. package/Tests/Server/Infrastructure/Postgres/AddKubernetesAiAgentAndAiDefaultsMigration.test.ts +623 -0
  297. package/Tests/Server/Infrastructure/Postgres/AddKubernetesClusterAiAccessMigration.test.ts +87 -10
  298. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  299. package/Tests/Server/Infrastructure/Postgres/AddTeamComplianceRuleScopeMigration.test.ts +622 -0
  300. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  301. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +119 -4
  302. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +196 -2
  303. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +123 -2
  304. package/Tests/Server/Services/AIServiceBalanceBlocker.test.ts +282 -0
  305. package/Tests/Server/Services/AlertMonitorClearWritesNull.test.ts +409 -0
  306. package/Tests/Server/Services/AlertMonitorEditFeed.test.ts +474 -0
  307. package/Tests/Server/Services/AlertMonitorEditGuard.test.ts +610 -0
  308. package/Tests/Server/Services/AutoRemediationClusterRuleEngine.test.ts +2 -0
  309. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  310. package/Tests/Server/Services/AutoRemediationRuleCommandRunners.test.ts +2 -1
  311. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  312. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  313. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  314. package/Tests/Server/Services/IncidentAffectedMonitorsEdit.test.ts +1360 -0
  315. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +3 -1
  316. package/Tests/Server/Services/IncidentSeverityDeletePausesComplianceRules.test.ts +488 -0
  317. package/Tests/Server/Services/KubernetesAiAgentJobService.test.ts +329 -0
  318. package/Tests/Server/Services/KubernetesAiAgentServiceHelpers.test.ts +618 -0
  319. package/Tests/Server/Services/KubernetesAiAgentServiceLifecycle.test.ts +1444 -0
  320. package/Tests/Server/Services/KubernetesAiAgentServiceRegister.test.ts +1666 -0
  321. package/Tests/Server/Services/KubernetesClusterAiAccessDeletedRunnerRemedy.test.ts +140 -111
  322. package/Tests/Server/Services/KubernetesClusterAiAccessGapNextSteps.test.ts +267 -105
  323. package/Tests/Server/Services/KubernetesClusterAiAccessLegacySuperseded.test.ts +440 -0
  324. package/Tests/Server/Services/KubernetesClusterAiAccessService.test.ts +567 -411
  325. package/Tests/Server/Services/KubernetesClusterAiAccessTarget.test.ts +1168 -0
  326. package/Tests/Server/Services/KubernetesClusterAiSettingsPermission.test.ts +410 -12
  327. package/Tests/Server/Services/KubernetesClusterServiceAiAccessFeed.test.ts +55 -0
  328. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  329. package/Tests/Server/Services/ProjectServiceNewProjectAiDefaults.test.ts +288 -0
  330. package/Tests/Server/Services/ProxmoxResourcePostgres.test.ts +5633 -0
  331. package/Tests/Server/Services/ProxmoxResourceService.test.ts +6458 -5
  332. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  333. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  334. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  335. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  336. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  337. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  338. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  339. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  340. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  341. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  342. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +153 -15
  343. package/Tests/Server/Services/RunnerJobEnqueueKubectlAiAgent.test.ts +620 -0
  344. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  345. package/Tests/Server/Services/RunnerJobKubectlWriteScope.test.ts +170 -3
  346. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  347. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  348. package/Tests/Server/Services/RunnerJobTimeoutReason.test.ts +72 -0
  349. package/Tests/Server/Services/RunnerServiceAgentName.test.ts +6 -4
  350. package/Tests/Server/Services/ScheduledMaintenanceAttachmentEdit.test.ts +2113 -0
  351. package/Tests/Server/Services/ScheduledMaintenanceMonitorRelease.test.ts +972 -0
  352. package/Tests/Server/Services/TeamComplianceSettingPostgres.test.ts +2237 -0
  353. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +3988 -138
  354. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  355. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  356. package/Tests/Server/Utils/AI/ClusterAccessContext.test.ts +107 -1
  357. package/Tests/Server/Utils/AI/IncidentPostmortemRunner.test.ts +351 -0
  358. package/Tests/Server/Utils/AI/InvestigationClusterAccessWiring.test.ts +2 -0
  359. package/Tests/Server/Utils/AI/InvestigationEligibility.test.ts +310 -0
  360. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  361. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  362. package/Tests/Server/Utils/AI/KubectlInvestigationToolkit.test.ts +70 -0
  363. package/Tests/Server/Utils/AI/KubectlJobRunnerFinishedJob.test.ts +58 -0
  364. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  365. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectl.test.ts +2 -0
  366. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectlOutcome.test.ts +2 -0
  367. package/Tests/Server/Utils/AI/RemediationCommandToolsLiveCluster.test.ts +2 -0
  368. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  369. package/Tests/Server/Utils/AI/RemediationCommandToolsRunnerScope.test.ts +125 -1
  370. package/Tests/Server/Utils/AI/RemediationExecutionRunner.test.ts +252 -3
  371. package/Tests/Server/Utils/AI/RemediationExecutionRunnerClusterMode.test.ts +2 -0
  372. package/Tests/Server/Utils/AI/RemediationExecutionRunnerProjectGates.test.ts +240 -0
  373. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  374. package/Tests/Server/Utils/AI/RemediationKubectlCopyParity.test.ts +2 -0
  375. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  376. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  377. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  378. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  379. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  380. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  381. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  382. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutor.test.ts +2 -0
  383. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  384. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorUnknownRun.test.ts +2 -0
  385. package/Tests/Server/Utils/AutoRemediation/CommandPlanVerification.test.ts +2 -0
  386. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  387. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  388. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  389. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  390. package/Tests/Server/Utils/Telemetry/HostAssetAttributesFromOtlp.test.ts +68 -0
  391. package/Tests/Server/Utils/Telemetry/InventoryMirrorPostgres.test.ts +370 -0
  392. package/Tests/Server/Utils/Telemetry/NetworkDeviceInventoryAttributes.test.ts +522 -0
  393. package/Tests/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.test.ts +155 -0
  394. package/Tests/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.test.ts +8631 -0
  395. package/Tests/Server/Utils/Telemetry/ProxmoxNativePush.test.ts +2852 -0
  396. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +367 -1
  397. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +75 -3
  398. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  399. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  400. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  401. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  402. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  403. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  404. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  405. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  406. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  407. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  408. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  409. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  410. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  411. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  412. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +25 -0
  413. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  414. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNames.test.ts +3119 -0
  415. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNamesPostgres.test.ts +1721 -0
  416. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  417. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  418. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  419. package/Tests/Types/Kubernetes/KubernetesAgentChartAiAccess.test.ts +581 -113
  420. package/Tests/Types/Kubernetes/KubernetesAiAgentAccessContract.test.ts +510 -0
  421. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  422. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  423. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  424. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  425. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  426. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  427. package/Tests/Types/Team/ComplianceRule.test.ts +1029 -0
  428. package/Tests/Types/Team/TeamComplianceStatus.test.ts +176 -0
  429. package/Tests/UI/Components/ActionButton/RowActions.test.tsx +641 -0
  430. package/Tests/UI/Components/ActionButton/RowActionsAccessibility.test.tsx +414 -0
  431. package/Tests/UI/Components/ActionButton/SplitActionButtons.test.ts +565 -0
  432. package/Tests/UI/Components/Charts/BarChartDragToSelect.test.tsx +386 -0
  433. package/Tests/UI/Components/Charts/ChartDoubleClickResetWithoutDblclick.test.tsx +1025 -0
  434. package/Tests/UI/Components/Charts/ChartGroupPageZoomHint.test.tsx +196 -0
  435. package/Tests/UI/Components/Charts/ChartPlaceholdersTakeDoubleClick.test.tsx +1861 -0
  436. package/Tests/UI/Components/Charts/ChartPlotClicksWhileResetArmed.test.tsx +382 -0
  437. package/Tests/UI/Components/Charts/ChartRangeSelection.test.tsx +704 -0
  438. package/Tests/UI/Components/Charts/ChartRangeSelectionDoubleClickReset.test.tsx +1481 -0
  439. package/Tests/UI/Components/Charts/ChartRangeSelectionRealRecharts.test.tsx +918 -0
  440. package/Tests/UI/Components/Charts/ChartRowBucketStart.test.ts +350 -0
  441. package/Tests/UI/Components/Charts/DoubleClickReset.test.tsx +996 -0
  442. package/Tests/UI/Components/Charts/HistogramDoubleClickResetRealRecharts.test.tsx +965 -0
  443. package/Tests/UI/Components/Charts/HistogramDriftThenRefetch.test.tsx +277 -0
  444. package/Tests/UI/Components/Charts/HistogramRangeSelectionDataChange.test.tsx +1757 -0
  445. package/Tests/UI/Components/Charts/HistogramRangeSelectionRealRecharts.test.tsx +560 -0
  446. package/Tests/UI/Components/Charts/TimeRangeZoom/ChartPageTimeRangeZoom.test.tsx +513 -0
  447. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.test.tsx +845 -0
  448. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.test.tsx +119 -0
  449. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHintTailwind.test.tsx +274 -0
  450. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.test.ts +433 -0
  451. package/Tests/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.test.tsx +863 -0
  452. package/Tests/UI/Components/Charts/TimeRangeZoom/ZoomDocsPromises.test.tsx +1147 -0
  453. package/Tests/UI/Components/DisabledButtonTooltip.test.tsx +14 -3
  454. package/Tests/UI/Components/EntityDropdownEscape.test.tsx +454 -0
  455. package/Tests/UI/Components/EntityDropdownFocusLeave.test.tsx +420 -0
  456. package/Tests/UI/Components/Forms/FormFieldDropdownDisabled.test.tsx +233 -0
  457. package/Tests/UI/Components/Input.test.tsx +80 -1
  458. package/Tests/UI/Components/LogsAnalyticsViewZoom.test.tsx +947 -0
  459. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +17 -5
  460. package/Tests/UI/Components/LogsViewerAnalyticsZoom.test.tsx +501 -0
  461. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +1 -1
  462. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +6 -4
  463. package/Tests/UI/Components/ModelTable/BaseModelTablePermissionGating.test.tsx +60 -8
  464. package/Tests/UI/Components/ModelTable/BaseModelTableRowActions.test.tsx +476 -0
  465. package/Tests/UI/Components/ModelTable/ModelTableWrapContent.test.tsx +7 -5
  466. package/Tests/UI/Components/MoreMenuPortal.test.tsx +623 -0
  467. package/Tests/UI/Components/StatusPage/ResourceGridActions.test.tsx +538 -0
  468. package/Tests/UI/Components/StatusPage/ResourceGridSelection.test.tsx +14 -4
  469. package/Tests/UI/Components/TableCellWrapping.test.tsx +3 -3
  470. package/Tests/UI/Components/TableRowActions.test.tsx +472 -0
  471. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +1 -1
  472. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerHistogramClickZoom.test.tsx +1 -1
  473. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerZoomScope.test.tsx +470 -0
  474. package/Tests/UI/Components/TelemetryViewer/UseViewerTimeRangeZoom.test.tsx +571 -0
  475. package/Tests/UI/Components/TelemetryViewer/ViewerDoubleClickResetUnderDataChange.test.tsx +928 -0
  476. package/Tests/UI/Components/TelemetryViewer/ViewerFollowsEnclosingZoom.test.tsx +1569 -0
  477. package/Tests/UI/Components/Toggle.test.tsx +66 -0
  478. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardRefreshNames.test.tsx +1023 -0
  479. package/Tests/UI/Rum/ChunkLoader.test.ts +201 -0
  480. package/Tests/UI/Rum/FidelityNotices.test.ts +54 -0
  481. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  482. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +72 -0
  483. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +1009 -9
  484. package/Tests/UI/Rum/ReplayRecordedAssets.test.ts +1069 -0
  485. package/Tests/UI/Rum/ReplayStage.test.tsx +913 -2
  486. package/Tests/UI/Rum/SessionReplayTable.test.tsx +443 -8
  487. package/Tests/UI/Rum/SessionReplayUsersTable.test.tsx +439 -9
  488. package/Tests/UI/Rum/UserFlowTablesRowActions.test.tsx +501 -0
  489. package/Tests/Utils/AiRemediation/KubectlPolicyChartRbacParity.test.ts +47 -19
  490. package/Tests/Utils/AiRemediation/KubectlWriteScopeCallerParity.test.ts +2 -0
  491. package/Tests/Utils/AiRemediation/KubernetesAiAgentPolicyCopyParity.test.ts +396 -0
  492. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  493. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  494. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  495. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  496. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  497. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  498. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  499. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  500. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  501. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  502. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  503. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  504. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  505. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  506. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  507. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  508. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  509. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  510. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +104 -1
  511. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  512. package/Types/AI/AIRunType.ts +9 -4
  513. package/Types/AI/InvestigationNotStartedReason.ts +6 -0
  514. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  515. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  516. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +197 -18
  517. package/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.ts +28 -0
  518. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +4 -2
  519. package/Types/Kubernetes/KubernetesClusterAiAccess.ts +263 -32
  520. package/Types/Kubernetes/KubernetesClusterAiAccessPermissions.ts +20 -16
  521. package/Types/Kubernetes/KubernetesClusterAiInsights.ts +81 -0
  522. package/Types/Monitor/ProxmoxAlertTemplates.ts +4 -4
  523. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  524. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  525. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  526. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  527. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  528. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  529. package/Types/Runbook/RunbookStepType.ts +29 -0
  530. package/Types/Team/ComplianceNotificationChannel.ts +22 -0
  531. package/Types/Team/ComplianceRule.ts +434 -0
  532. package/Types/Team/ComplianceRuleType.ts +21 -0
  533. package/Types/Team/TeamComplianceStatus.ts +81 -0
  534. package/Types/Telemetry/TelemetryIngestSurface.ts +40 -0
  535. package/UI/Components/ActionButton/ActionButtonSchema.ts +21 -0
  536. package/UI/Components/ActionButton/RowActions.tsx +238 -0
  537. package/UI/Components/ActionButton/SplitActionButtons.ts +173 -0
  538. package/UI/Components/Charts/Area/AreaChart.tsx +32 -2
  539. package/UI/Components/Charts/Bar/BarChart.tsx +40 -3
  540. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +37 -11
  541. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +80 -243
  542. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +122 -31
  543. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +95 -244
  544. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +214 -0
  545. package/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.ts +32 -0
  546. package/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.ts +599 -0
  547. package/UI/Components/Charts/Line/LineChart.tsx +32 -2
  548. package/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.tsx +70 -0
  549. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.tsx +226 -0
  550. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.tsx +73 -0
  551. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.ts +163 -0
  552. package/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.ts +215 -0
  553. package/UI/Components/Charts/Utils/DataPoint.ts +20 -8
  554. package/UI/Components/Charts/Utils/XAxis.ts +75 -0
  555. package/UI/Components/Charts/Utils/useHistogramRangeSelection.ts +493 -59
  556. package/UI/Components/EntityDropdown/EntityDropdown.tsx +107 -11
  557. package/UI/Components/Forms/Fields/FormField.tsx +10 -0
  558. package/UI/Components/Input/Input.tsx +90 -57
  559. package/UI/Components/List/ListRow.tsx +8 -74
  560. package/UI/Components/LogsViewer/LogsViewer.tsx +40 -11
  561. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +22 -6
  562. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +209 -6
  563. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +127 -84
  564. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +3 -0
  565. package/UI/Components/ModelTable/BaseModelTable.tsx +12 -1
  566. package/UI/Components/MoreMenu/MoreMenu.tsx +365 -30
  567. package/UI/Components/MoreMenu/MoreMenuItem.tsx +70 -17
  568. package/UI/Components/OrderedStatesList/Item.tsx +7 -72
  569. package/UI/Components/StatusPage/ResourceGrid.tsx +126 -77
  570. package/UI/Components/Table/TableBody.tsx +8 -2
  571. package/UI/Components/Table/TableRow.tsx +20 -140
  572. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +41 -10
  573. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +47 -9
  574. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +25 -6
  575. package/UI/Components/TelemetryViewer/types.ts +2 -0
  576. package/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.ts +448 -0
  577. package/UI/Components/Toggle/Toggle.tsx +26 -0
  578. package/UI/Components/Tooltip/Tooltip.tsx +8 -1
  579. package/UI/Styles/Theme.css +14 -2
  580. package/Utils/AI/InvestigationReport.ts +121 -15
  581. package/Utils/AiRemediation/CommandPolicy.ts +1 -1
  582. package/Utils/AiRemediation/KubectlPolicy.ts +1 -1
  583. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  584. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  585. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  586. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  587. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  588. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  589. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  590. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  591. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  592. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  593. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  594. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  595. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  596. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +46 -2
  597. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  598. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  599. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  600. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  601. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  602. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  603. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  604. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  605. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  606. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  607. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  608. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  609. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  610. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  611. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  612. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  613. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  614. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js +385 -0
  615. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js.map +1 -0
  616. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +3 -3
  617. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  618. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  619. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  620. package/build/dist/Models/DatabaseModels/Project.js +56 -5
  621. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  622. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  623. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  624. package/build/dist/Models/DatabaseModels/ProxmoxResource.js +51 -1
  625. package/build/dist/Models/DatabaseModels/ProxmoxResource.js.map +1 -1
  626. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  627. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  628. package/build/dist/Models/DatabaseModels/RunnerJob.js +219 -0
  629. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  630. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js +134 -3
  631. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js.map +1 -1
  632. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  633. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  634. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  635. package/build/dist/Server/API/AIChatAPI.js +6 -1
  636. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  637. package/build/dist/Server/API/AIInvestigationAPI.js +17 -10
  638. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  639. package/build/dist/Server/API/AutoRemediationAPI.js +374 -24
  640. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  641. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js +436 -22
  642. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js.map +1 -1
  643. package/build/dist/Server/API/MicrosoftTeamsAPI.js +69 -13
  644. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  645. package/build/dist/Server/API/ProxmoxResourceAPI.js +187 -14
  646. package/build/dist/Server/API/ProxmoxResourceAPI.js.map +1 -1
  647. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  648. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  649. package/build/dist/Server/Infrastructure/GlobalCache.js +27 -0
  650. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  651. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js +50 -0
  652. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js.map +1 -0
  653. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js +84 -0
  654. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js.map +1 -0
  655. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js +31 -0
  656. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js.map +1 -0
  657. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  658. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  659. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  660. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  661. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  662. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  663. package/build/dist/Server/Middleware/TelemetryIngest.js +41 -19
  664. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  665. package/build/dist/Server/Services/AIService.js +59 -1
  666. package/build/dist/Server/Services/AIService.js.map +1 -1
  667. package/build/dist/Server/Services/AlertService.js +248 -13
  668. package/build/dist/Server/Services/AlertService.js.map +1 -1
  669. package/build/dist/Server/Services/AlertSeverityService.js +51 -15
  670. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  671. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  672. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  673. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  674. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  675. package/build/dist/Server/Services/AutoRemediationRuleService.js +1 -1
  676. package/build/dist/Server/Services/AutoRemediationRuleService.js.map +1 -1
  677. package/build/dist/Server/Services/CephClusterService.js +104 -0
  678. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  679. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  680. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  681. package/build/dist/Server/Services/DockerHostService.js +104 -0
  682. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  683. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  684. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  685. package/build/dist/Server/Services/HostService.js +114 -2
  686. package/build/dist/Server/Services/HostService.js.map +1 -1
  687. package/build/dist/Server/Services/IncidentService.js +199 -72
  688. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  689. package/build/dist/Server/Services/IncidentSeverityService.js +51 -15
  690. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  691. package/build/dist/Server/Services/KubernetesAiAgentJobService.js +158 -0
  692. package/build/dist/Server/Services/KubernetesAiAgentJobService.js.map +1 -0
  693. package/build/dist/Server/Services/KubernetesAiAgentService.js +1145 -0
  694. package/build/dist/Server/Services/KubernetesAiAgentService.js.map +1 -0
  695. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js +842 -371
  696. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js.map +1 -1
  697. package/build/dist/Server/Services/KubernetesClusterService.js +86 -17
  698. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  699. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  700. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  701. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  702. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  703. package/build/dist/Server/Services/ProjectService.js +22 -0
  704. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  705. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  706. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  707. package/build/dist/Server/Services/ProxmoxResourceService.js +258 -3
  708. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  709. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  710. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  711. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  712. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  713. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  714. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  715. package/build/dist/Server/Services/RunnerJobService.js +522 -97
  716. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  717. package/build/dist/Server/Services/RunnerService.js +1 -1
  718. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  719. package/build/dist/Server/Services/ScheduledMaintenanceService.js +535 -3
  720. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  721. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +316 -46
  722. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  723. package/build/dist/Server/Services/TeamComplianceSettingService.js +833 -7
  724. package/build/dist/Server/Services/TeamComplianceSettingService.js.map +1 -1
  725. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  726. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  727. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  728. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  729. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  730. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  731. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js +52 -9
  732. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js.map +1 -1
  733. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js +13 -9
  734. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js.map +1 -1
  735. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js +7 -3
  736. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js.map +1 -1
  737. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1056 -43
  738. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  739. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +683 -42
  740. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  741. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  742. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  743. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  744. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  745. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  746. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  747. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  748. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  749. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  750. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  751. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  752. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  753. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  754. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  755. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +89 -9
  756. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  757. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  758. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  759. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  760. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  761. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js +30 -3
  762. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js.map +1 -1
  763. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js +13 -2
  764. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js.map +1 -1
  765. package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js +1 -1
  766. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +294 -15
  767. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  768. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  769. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  770. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  771. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  772. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  773. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  774. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  775. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  776. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +111 -6
  777. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  778. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +40 -4
  779. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  780. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js +296 -0
  781. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js.map +1 -0
  782. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js +699 -0
  783. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js.map +1 -0
  784. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +77 -19
  785. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  786. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  787. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  788. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  789. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  790. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  791. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  792. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  793. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  794. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  795. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  796. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  797. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  798. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +768 -152
  799. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  800. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  801. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  802. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  803. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  804. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  805. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  806. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  807. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  808. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  809. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  810. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  811. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  812. package/build/dist/Types/AI/AIRunType.js +9 -4
  813. package/build/dist/Types/AI/AIRunType.js.map +1 -1
  814. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  815. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  816. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  817. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  818. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +126 -45
  819. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  820. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js +28 -0
  821. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js.map +1 -0
  822. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js +72 -16
  823. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js.map +1 -1
  824. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js +20 -16
  825. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js.map +1 -1
  826. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js +17 -0
  827. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js.map +1 -0
  828. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js +4 -4
  829. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js.map +1 -1
  830. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  831. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  832. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  833. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  834. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  835. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  836. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  837. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  838. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  839. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  840. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  841. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  842. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  843. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  844. package/build/dist/Types/Team/ComplianceNotificationChannel.js +23 -0
  845. package/build/dist/Types/Team/ComplianceNotificationChannel.js.map +1 -0
  846. package/build/dist/Types/Team/ComplianceRule.js +304 -0
  847. package/build/dist/Types/Team/ComplianceRule.js.map +1 -0
  848. package/build/dist/Types/Team/ComplianceRuleType.js +21 -0
  849. package/build/dist/Types/Team/ComplianceRuleType.js.map +1 -1
  850. package/build/dist/Types/Team/TeamComplianceStatus.js +2 -0
  851. package/build/dist/Types/Team/TeamComplianceStatus.js.map +1 -0
  852. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +36 -0
  853. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  854. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js +20 -1
  855. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js.map +1 -1
  856. package/build/dist/UI/Components/ActionButton/RowActions.js +113 -0
  857. package/build/dist/UI/Components/ActionButton/RowActions.js.map +1 -0
  858. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js +71 -0
  859. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js.map +1 -0
  860. package/build/dist/UI/Components/Charts/Area/AreaChart.js +16 -1
  861. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  862. package/build/dist/UI/Components/Charts/Bar/BarChart.js +16 -1
  863. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  864. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +27 -5
  865. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  866. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +61 -194
  867. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  868. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +84 -26
  869. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  870. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +75 -196
  871. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  872. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +133 -0
  873. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -1
  874. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js +26 -0
  875. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js.map +1 -0
  876. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js +371 -0
  877. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js.map +1 -0
  878. package/build/dist/UI/Components/Charts/Line/LineChart.js +16 -1
  879. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  880. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js +35 -0
  881. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js.map +1 -0
  882. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js +107 -0
  883. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js.map +1 -0
  884. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js +41 -0
  885. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js.map +1 -0
  886. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js +117 -0
  887. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js.map +1 -0
  888. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js +121 -0
  889. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js.map +1 -0
  890. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +15 -7
  891. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  892. package/build/dist/UI/Components/Charts/Utils/XAxis.js +70 -0
  893. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  894. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js +305 -38
  895. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js.map +1 -1
  896. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +89 -14
  897. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  898. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -2
  899. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  900. package/build/dist/UI/Components/Input/Input.js +69 -54
  901. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  902. package/build/dist/UI/Components/List/ListRow.js +4 -39
  903. package/build/dist/UI/Components/List/ListRow.js.map +1 -1
  904. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +31 -8
  905. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  906. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +10 -1
  907. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  908. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +139 -21
  909. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  910. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +30 -8
  911. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  912. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  913. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  914. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +10 -0
  915. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  916. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +253 -14
  917. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  918. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +43 -13
  919. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
  920. package/build/dist/UI/Components/OrderedStatesList/Item.js +2 -38
  921. package/build/dist/UI/Components/OrderedStatesList/Item.js.map +1 -1
  922. package/build/dist/UI/Components/StatusPage/ResourceGrid.js +53 -15
  923. package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -1
  924. package/build/dist/UI/Components/Table/TableBody.js +6 -2
  925. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  926. package/build/dist/UI/Components/Table/TableRow.js +12 -75
  927. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  928. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +32 -7
  929. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  930. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +27 -9
  931. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  932. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +11 -1
  933. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  934. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js +272 -0
  935. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js.map +1 -0
  936. package/build/dist/UI/Components/Toggle/Toggle.js +14 -1
  937. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  938. package/build/dist/UI/Components/Tooltip/Tooltip.js +1 -1
  939. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  940. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  941. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  942. package/build/dist/Utils/AiRemediation/CommandPolicy.js +1 -1
  943. package/build/dist/Utils/AiRemediation/CommandPolicy.js.map +1 -1
  944. package/build/dist/Utils/AiRemediation/KubectlPolicy.js +1 -1
  945. package/build/dist/Utils/AiRemediation/KubectlPolicy.js.map +1 -1
  946. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  947. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  948. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  949. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  950. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  951. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  952. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  953. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  954. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  955. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  956. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  957. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  958. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  959. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  960. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  961. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  962. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  963. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  964. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  965. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  966. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  967. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  968. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  969. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  970. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  971. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  972. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +27 -1
  973. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -1
  974. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  975. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  976. package/package.json +1 -1
@@ -0,0 +1,3119 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
2
+
3
+ /*
4
+ * Issue #4106: Microsoft Teams group chats were listed by their members'
5
+ * names ("Alice, Bob, Carol + 4 more") instead of the name the group chat has
6
+ * in Teams.
7
+ *
8
+ * Teams does not reliably put a group chat's name on the bot activities
9
+ * OneUptime captures chats from, so the name is read from Microsoft Graph
10
+ * (GET /chats/{id}, which needs the ChatSettings.Read.Chat RSC permission or
11
+ * the tenant-wide Chat.ReadBasic.WhereInstalled). Member names are only the
12
+ * fallback, for chats with no name or whose name cannot be read — and a name
13
+ * that was read once is kept when a later read fails.
14
+ *
15
+ * Covered here:
16
+ * - getChatDisplayName: surrogate-safe truncation and the stored member count.
17
+ * - buildCapturedChat: the record a capture stores, on top of the stored one.
18
+ * - getTenantChatContext: the one read a capture makes before storing.
19
+ * - capture (conversationUpdate / installationUpdate / message backfill).
20
+ * - isChatCapturedForTenant: group chats without a member count are stale.
21
+ * - getGroupChatTopicFromGraph: the Graph read and how each answer maps.
22
+ * - refreshChatNamesForProject: what Refresh Chats does.
23
+ * - renameChatsInProjectAuthTokens: the write behind a refresh.
24
+ */
25
+
26
+ jest.mock("../../../../Server/EnvironmentConfig", () => {
27
+ return {
28
+ ...(jest.requireActual("../../../../Server/EnvironmentConfig") as Record<
29
+ string,
30
+ unknown
31
+ >),
32
+ MicrosoftTeamsAppClientId: "11111111-2222-3333-4444-555555555555",
33
+ MicrosoftTeamsAppClientSecret: "test-secret",
34
+ MicrosoftTeamsAppTenantId: "test-tenant",
35
+ };
36
+ });
37
+
38
+ /*
39
+ * The repo-wide botbuilder manual mock (Tests/__mocks__/botbuilder.js) does
40
+ * not expose TeamsInfo or MessageFactory.attachment, both of which the chat
41
+ * code paths use — so this file supplies its own richer factory.
42
+ */
43
+ jest.mock("botbuilder", () => {
44
+ return {
45
+ CloudAdapter: class CloudAdapter {},
46
+ ConfigurationBotFrameworkAuthentication: class ConfigurationBotFrameworkAuthentication {},
47
+ TeamsActivityHandler: class TeamsActivityHandler {},
48
+ TurnContext: class TurnContext {},
49
+ ActivityHandler: class ActivityHandler {},
50
+ MessageFactory: {
51
+ text: jest.fn(),
52
+ attachment: jest.fn((attachment: unknown) => {
53
+ return { type: "message", attachments: [attachment] };
54
+ }),
55
+ },
56
+ CardFactory: { heroCard: jest.fn() },
57
+ TeamsInfo: {
58
+ getMembers: jest.fn(),
59
+ getPagedMembers: jest.fn(),
60
+ },
61
+ };
62
+ });
63
+
64
+ import MicrosoftTeamsUtil, {
65
+ MICROSOFT_TEAMS_CHAT_TOPIC_READ_PERMISSION,
66
+ MicrosoftTeamsChatNameRefreshResult,
67
+ MicrosoftTeamsChatNameUpdate,
68
+ MicrosoftTeamsChatTopicLookup,
69
+ MicrosoftTeamsChatTopicLookupStatus,
70
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
71
+ import WorkspaceProjectAuthTokenService from "../../../../Server/Services/WorkspaceProjectAuthTokenService";
72
+ import WorkspaceProjectAuthToken, {
73
+ MicrosoftTeamsChat,
74
+ MicrosoftTeamsMiscData,
75
+ } from "../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
76
+ import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
77
+ import ObjectID from "../../../../Types/ObjectID";
78
+ import LIMIT_MAX from "../../../../Types/Database/LimitMax";
79
+ import OneUptimeDate from "../../../../Types/Date";
80
+ import { JSONObject } from "../../../../Types/JSON";
81
+ import API from "../../../../Utils/API";
82
+ import HTTPResponse from "../../../../Types/API/HTTPResponse";
83
+ import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
84
+ import logger from "../../../../Server/Utils/Logger";
85
+ import {
86
+ TeamsInfo,
87
+ type TeamsChannelAccount,
88
+ type TeamsPagedMembersResult,
89
+ type TurnContext,
90
+ } from "botbuilder";
91
+
92
+ const BOT_RECIPIENT_ID: string = "bot-recipient-id";
93
+ const TENANT_ID: string = "tenant-1";
94
+ const ACCESS_TOKEN: string = "header.payload.signature";
95
+ const SERVICE_URL: string = "https://smba.trafficmanager.net/amer/";
96
+ const OLD_SERVICE_URL: string = "https://smba.trafficmanager.net/emea/";
97
+ const NOW: string = "2026-09-28T12:00:00.000Z";
98
+ const ADDED_AT: string = "2026-09-01T00:00:00.000Z";
99
+
100
+ const GROUP_CHAT_ID: string = "19:3a9c1f0e5b7d4e2f8a6b0c1d2e3f4a5b@thread.v2";
101
+ // The same id as Graph's path wants it: ':' and '@' percent-encoded.
102
+ const ENCODED_GROUP_CHAT_ID: string =
103
+ "19%3A3a9c1f0e5b7d4e2f8a6b0c1d2e3f4a5b%40thread.v2";
104
+ const PERSONAL_CHAT_ID: string = "a:1personal-chat";
105
+
106
+ // The chat from the issue: named in Teams, listed by its members in OneUptime.
107
+ const ISSUE_MEMBERS: Array<string> = [
108
+ "Bibishek G S Steephensen [Contractor]",
109
+ "Karthik Kallam [Contractor]",
110
+ "Robin Example",
111
+ "Sam Example",
112
+ ];
113
+ const ISSUE_AAD_IDS: Array<string> = ["aad-0", "aad-1", "aad-2", "aad-3"];
114
+ // Only the names a member-named chat shows are stored — never the roster.
115
+ const ISSUE_MEMBERS_SHOWN: Array<string> = [
116
+ "Bibishek G S Steephensen [Contractor]",
117
+ "Karthik Kallam [Contractor]",
118
+ "Robin Example",
119
+ ];
120
+ // Cut at 80 characters, which is why the chats in the issue ended in "...".
121
+ const ISSUE_MEMBER_NAME: string =
122
+ "Bibishek G S Steephensen [Contractor], Karthik Kallam [Contractor], Robin Examp…";
123
+
124
+ const TEAMS_NAME: string = "Platform On-Call";
125
+
126
+ const FOUND: MicrosoftTeamsChatTopicLookup = {
127
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
128
+ topic: TEAMS_NAME,
129
+ };
130
+ const NO_TOPIC: MicrosoftTeamsChatTopicLookup = {
131
+ status: MicrosoftTeamsChatTopicLookupStatus.NoTopic,
132
+ };
133
+ const DENIED: MicrosoftTeamsChatTopicLookup = {
134
+ status: MicrosoftTeamsChatTopicLookupStatus.PermissionDenied,
135
+ };
136
+ const UNKNOWN: MicrosoftTeamsChatTopicLookup = {
137
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
138
+ };
139
+
140
+ type CaptureInput = Parameters<typeof MicrosoftTeamsUtil.buildCapturedChat>[0];
141
+ type Roster = NonNullable<CaptureInput["roster"]>;
142
+
143
+ interface TenantChatContext {
144
+ projectId: ObjectID | null;
145
+ storedChat: MicrosoftTeamsChat | undefined;
146
+ }
147
+
148
+ interface Deferred<T> {
149
+ promise: Promise<T>;
150
+ resolve: (value: T) => void;
151
+ reject: (reason: unknown) => void;
152
+ }
153
+
154
+ function createDeferred<T>(): Deferred<T> {
155
+ let resolvePromise!: (value: T) => void;
156
+ let rejectPromise!: (reason: unknown) => void;
157
+
158
+ const promise: Promise<T> = new Promise<T>(
159
+ (resolve: (value: T) => void, reject: (reason: unknown) => void) => {
160
+ resolvePromise = resolve;
161
+ rejectPromise = reject;
162
+ },
163
+ );
164
+
165
+ return {
166
+ promise: promise,
167
+ resolve: resolvePromise,
168
+ reject: rejectPromise,
169
+ };
170
+ }
171
+
172
+ /*
173
+ * A macrotask (setTimeout 0) drains every pending microtask first, so the
174
+ * code under test runs as far as it can before the test looks. The jsdom
175
+ * test environment has no setImmediate.
176
+ */
177
+ async function flushMicrotasks(): Promise<void> {
178
+ await new Promise<void>((resolve: () => void) => {
179
+ setTimeout(resolve, 0);
180
+ });
181
+ }
182
+
183
+ // Drains the queue until the condition holds; never waits on the clock.
184
+ async function waitUntil(condition: () => boolean): Promise<void> {
185
+ for (let attempt: number = 0; attempt < 50; attempt++) {
186
+ if (condition()) {
187
+ return;
188
+ }
189
+ await flushMicrotasks();
190
+ }
191
+ throw new Error("Condition never became true");
192
+ }
193
+
194
+ // True when the string holds half of an emoji: what Postgres jsonb refuses.
195
+ function hasLoneSurrogate(value: string): boolean {
196
+ for (let index: number = 0; index < value.length; index++) {
197
+ const codeUnit: number = value.charCodeAt(index);
198
+
199
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
200
+ const nextCodeUnit: number = value.charCodeAt(index + 1);
201
+ if (!(nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)) {
202
+ return true;
203
+ }
204
+ index++; // a whole pair.
205
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
206
+ return true;
207
+ }
208
+ }
209
+
210
+ return false;
211
+ }
212
+
213
+ // What a promise rejected with; undefined when it resolved.
214
+ async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
215
+ try {
216
+ await promise;
217
+ } catch (error) {
218
+ return error;
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ function tenantChatContext(): (data: {
224
+ tenantId: string;
225
+ chatId: string;
226
+ }) => Promise<TenantChatContext> {
227
+ return (MicrosoftTeamsUtil as any).getTenantChatContext.bind(
228
+ MicrosoftTeamsUtil,
229
+ );
230
+ }
231
+
232
+ function isChatCapturedForTenant(data: {
233
+ tenantId: string;
234
+ chatId: string;
235
+ serviceUrl?: string | undefined;
236
+ }): Promise<boolean> {
237
+ return (MicrosoftTeamsUtil as any).isChatCapturedForTenant(data);
238
+ }
239
+
240
+ function buildTurnContext(): TurnContext {
241
+ return {
242
+ activity: {
243
+ recipient: { id: BOT_RECIPIENT_ID },
244
+ serviceUrl: SERVICE_URL,
245
+ conversation: { id: "ctx-conversation-id" },
246
+ },
247
+ } as unknown as TurnContext;
248
+ }
249
+
250
+ function mockMembers(members: Array<Partial<TeamsChannelAccount>>): void {
251
+ (TeamsInfo.getPagedMembers as jest.Mock).mockResolvedValue({
252
+ members: members as Array<TeamsChannelAccount>,
253
+ continuationToken: undefined,
254
+ } as unknown as TeamsPagedMembersResult);
255
+ }
256
+
257
+ function mockIssueMembers(): void {
258
+ mockMembers([
259
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
260
+ ...ISSUE_MEMBERS.map((name: string, index: number) => {
261
+ return {
262
+ id: `user-${index}`,
263
+ name: name,
264
+ aadObjectId: `aad-${index}`,
265
+ };
266
+ }),
267
+ ]);
268
+ }
269
+
270
+ function mockRosterFailure(): void {
271
+ (TeamsInfo.getPagedMembers as jest.Mock).mockRejectedValue(
272
+ new Error("roster unavailable"),
273
+ );
274
+ }
275
+
276
+ function issueRoster(): Roster {
277
+ return {
278
+ memberNames: [...ISSUE_MEMBERS],
279
+ memberAadObjectIds: [...ISSUE_AAD_IDS],
280
+ };
281
+ }
282
+
283
+ function graphOk(data: JSONObject): HTTPResponse<JSONObject> {
284
+ return new HTTPResponse<JSONObject>(200, data, {});
285
+ }
286
+
287
+ // Built the way API.fetch builds one: the status and Graph's own error body.
288
+ function graphError(statusCode: number, body: JSONObject): HTTPErrorResponse {
289
+ return new HTTPErrorResponse(statusCode, body, {});
290
+ }
291
+
292
+ // A group chat as this version captures it when Graph gave no name.
293
+ function buildChat(
294
+ overrides?: Partial<MicrosoftTeamsChat>,
295
+ ): MicrosoftTeamsChat {
296
+ return {
297
+ id: GROUP_CHAT_ID,
298
+ name: ISSUE_MEMBER_NAME,
299
+ chatType: "groupChat",
300
+ serviceUrl: SERVICE_URL,
301
+ addedAt: ADDED_AT,
302
+ memberAadObjectIds: [...ISSUE_AAD_IDS],
303
+ memberNames: [...ISSUE_MEMBERS_SHOWN],
304
+ memberCount: 4,
305
+ ...(overrides || {}),
306
+ };
307
+ }
308
+
309
+ // A group chat as this version captures it when Graph shared its name.
310
+ function buildNamedChat(
311
+ overrides?: Partial<MicrosoftTeamsChat>,
312
+ ): MicrosoftTeamsChat {
313
+ return buildChat({
314
+ name: TEAMS_NAME,
315
+ topic: TEAMS_NAME,
316
+ ...(overrides || {}),
317
+ });
318
+ }
319
+
320
+ /*
321
+ * A group chat captured before this shipped: no Teams name, no member names,
322
+ * no member count — only the name built from the members back then.
323
+ */
324
+ function buildLegacyChat(
325
+ overrides?: Partial<MicrosoftTeamsChat>,
326
+ ): MicrosoftTeamsChat {
327
+ const chat: MicrosoftTeamsChat = buildChat({
328
+ name: "Alice, Bob, Carol + 4 more",
329
+ ...(overrides || {}),
330
+ });
331
+ delete chat.memberNames;
332
+ delete chat.memberCount;
333
+ delete chat.memberAadObjectIds;
334
+ return chat;
335
+ }
336
+
337
+ function buildPersonalChat(
338
+ overrides?: Partial<MicrosoftTeamsChat>,
339
+ ): MicrosoftTeamsChat {
340
+ return {
341
+ id: PERSONAL_CHAT_ID,
342
+ name: "Jane Doe",
343
+ chatType: "personal",
344
+ serviceUrl: SERVICE_URL,
345
+ addedAt: ADDED_AT,
346
+ memberAadObjectIds: ["aad-jane"],
347
+ memberNames: ["Jane Doe"],
348
+ memberCount: 1,
349
+ ...(overrides || {}),
350
+ };
351
+ }
352
+
353
+ function chatsById(
354
+ chats: Array<MicrosoftTeamsChat>,
355
+ ): Record<string, MicrosoftTeamsChat> {
356
+ const availableChats: Record<string, MicrosoftTeamsChat> = {};
357
+ for (const chat of chats) {
358
+ availableChats[chat.id] = chat;
359
+ }
360
+ return availableChats;
361
+ }
362
+
363
+ // A deep copy, so a stored row never shares objects with the test's fixtures.
364
+ function cloneChats(
365
+ chats: Record<string, MicrosoftTeamsChat>,
366
+ ): Record<string, MicrosoftTeamsChat> {
367
+ return JSON.parse(JSON.stringify(chats)) as Record<
368
+ string,
369
+ MicrosoftTeamsChat
370
+ >;
371
+ }
372
+
373
+ function buildAuthRow(data: {
374
+ projectId?: ObjectID | undefined;
375
+ workspaceProjectId?: string | undefined;
376
+ availableChats?: Record<string, MicrosoftTeamsChat> | undefined;
377
+ miscData?: MicrosoftTeamsMiscData | undefined;
378
+ withoutMiscData?: boolean | undefined;
379
+ }): WorkspaceProjectAuthToken {
380
+ const row: WorkspaceProjectAuthToken = new WorkspaceProjectAuthToken();
381
+ row._id = ObjectID.generate().toString();
382
+ if (data.projectId) {
383
+ row.projectId = data.projectId;
384
+ }
385
+ if (data.workspaceProjectId !== undefined) {
386
+ row.workspaceProjectId = data.workspaceProjectId;
387
+ }
388
+ if (!data.withoutMiscData) {
389
+ row.miscData = (data.miscData ||
390
+ (data.availableChats
391
+ ? { availableChats: data.availableChats }
392
+ : {})) as MicrosoftTeamsMiscData;
393
+ }
394
+ return row;
395
+ }
396
+
397
+ let apiGetSpy: jest.SpyInstance;
398
+ let tokenSpy: jest.SpyInstance;
399
+
400
+ beforeEach(() => {
401
+ (TeamsInfo.getPagedMembers as jest.Mock).mockReset();
402
+ (TeamsInfo.getMembers as jest.Mock).mockReset();
403
+
404
+ tokenSpy = jest
405
+ .spyOn(MicrosoftTeamsUtil, "getValidAccessToken")
406
+ .mockResolvedValue(ACCESS_TOKEN);
407
+
408
+ // Every test that reaches Graph sets its own answer; the default is loud.
409
+ apiGetSpy = jest.spyOn(API, "get").mockImplementation(async () => {
410
+ throw new Error("Unexpected Graph call in test");
411
+ });
412
+ });
413
+
414
+ afterEach(() => {
415
+ jest.restoreAllMocks();
416
+ });
417
+
418
+ describe("ChatSettings.Read.Chat permission constant", () => {
419
+ test("names the RSC permission Graph's GET /chats/{id} accepts for app-only tokens", () => {
420
+ expect(MICROSOFT_TEAMS_CHAT_TOPIC_READ_PERMISSION).toBe(
421
+ "ChatSettings.Read.Chat",
422
+ );
423
+ });
424
+ });
425
+
426
+ describe("MicrosoftTeamsUtil.getChatDisplayName — truncation and member count", () => {
427
+ test("an emoji straddling the cut is dropped whole, never split in half", () => {
428
+ // 78 x's put the emoji's two halves at positions 78 and 79: the cut is 79.
429
+ const topic: string = `${"x".repeat(78)}🔥 incident war room`;
430
+
431
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
432
+ chatType: "groupChat",
433
+ topic: topic,
434
+ memberNames: [],
435
+ });
436
+
437
+ expect(name).toBe(`${"x".repeat(78)}…`);
438
+ expect(name.length).toBeLessThanOrEqual(80);
439
+ // The character before the ellipsis is not the first half of an emoji.
440
+ const beforeEllipsis: number = name.charCodeAt(name.length - 2);
441
+ expect(beforeEllipsis >= 0xd800 && beforeEllipsis <= 0xdbff).toBe(false);
442
+ expect(JSON.stringify(name)).not.toContain("\\ud83d");
443
+ expect(hasLoneSurrogate(name)).toBe(false);
444
+ });
445
+
446
+ test("an emoji entirely before the cut is kept whole", () => {
447
+ // 77 x's put the emoji at positions 77 and 78 — both inside the 79 kept.
448
+ const topic: string = `${"x".repeat(77)}🔥${"y".repeat(20)}`;
449
+
450
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
451
+ chatType: "groupChat",
452
+ topic: topic,
453
+ memberNames: [],
454
+ });
455
+
456
+ expect(name).toBe(`${"x".repeat(77)}🔥…`);
457
+ expect(name).toHaveLength(80);
458
+ expect(hasLoneSurrogate(name)).toBe(false);
459
+ });
460
+
461
+ test("an emoji at the start of a long name survives the cut", () => {
462
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
463
+ chatType: "groupChat",
464
+ topic: `🔥 ${"Incident bridge ".repeat(8)}`,
465
+ memberNames: [],
466
+ });
467
+
468
+ expect(name.startsWith("🔥 Incident bridge")).toBe(true);
469
+ expect(name).toHaveLength(80);
470
+ expect(name.endsWith("…")).toBe(true);
471
+ expect(hasLoneSurrogate(name)).toBe(false);
472
+ });
473
+
474
+ test("member names are cut surrogate-safely too", () => {
475
+ // The joined names put an emoji's first half at position 78.
476
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
477
+ chatType: "groupChat",
478
+ memberNames: ["x".repeat(40), `${"y".repeat(36)}😀 Smith`, "Carol"],
479
+ });
480
+
481
+ expect(name).toBe(`${"x".repeat(40)}, ${"y".repeat(36)}…`);
482
+ expect(hasLoneSurrogate(name)).toBe(false);
483
+ });
484
+
485
+ test("a name with an emoji that fits is returned untouched", () => {
486
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
487
+ chatType: "groupChat",
488
+ topic: "🔥 Incident war room",
489
+ memberNames: [],
490
+ });
491
+
492
+ expect(name).toBe("🔥 Incident war room");
493
+ });
494
+
495
+ test("the issue's chat, stored as its first three members and a count, keeps its '+ 1 more'", () => {
496
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
497
+ chatType: "groupChat",
498
+ memberNames: [...ISSUE_MEMBERS_SHOWN],
499
+ memberCount: 4,
500
+ });
501
+
502
+ expect(name).toBe(ISSUE_MEMBER_NAME);
503
+ });
504
+
505
+ test("memberCount drives '+ N more' when memberNames holds only the first three", () => {
506
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
507
+ chatType: "groupChat",
508
+ memberNames: ["Alice", "Bob", "Carol"],
509
+ memberCount: 7,
510
+ });
511
+
512
+ expect(name).toBe("Alice, Bob, Carol + 4 more");
513
+ });
514
+
515
+ test("a memberCount equal to the names shown adds nothing", () => {
516
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
517
+ chatType: "groupChat",
518
+ memberNames: ["Alice", "Bob", "Carol"],
519
+ memberCount: 3,
520
+ });
521
+
522
+ expect(name).toBe("Alice, Bob, Carol");
523
+ });
524
+
525
+ test("a memberCount smaller than the names given is ignored — the names are counted", () => {
526
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
527
+ chatType: "groupChat",
528
+ memberNames: ["Alice", "Bob", "Carol", "Dave", "Erin"],
529
+ memberCount: 2,
530
+ });
531
+
532
+ expect(name).toBe("Alice, Bob, Carol + 2 more");
533
+ });
534
+
535
+ test("a memberCount of 0 counts the names given", () => {
536
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
537
+ chatType: "groupChat",
538
+ memberNames: ["Alice", "Bob"],
539
+ memberCount: 0,
540
+ });
541
+
542
+ expect(name).toBe("Alice, Bob");
543
+ });
544
+
545
+ test("a memberCount without any names is still 'Group chat'", () => {
546
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
547
+ chatType: "groupChat",
548
+ memberNames: [],
549
+ memberCount: 5,
550
+ });
551
+
552
+ expect(name).toBe("Group chat");
553
+ });
554
+
555
+ test("a Teams name wins over the member count", () => {
556
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
557
+ chatType: "groupChat",
558
+ topic: TEAMS_NAME,
559
+ memberNames: ["Alice", "Bob", "Carol"],
560
+ memberCount: 9,
561
+ });
562
+
563
+ expect(name).toBe(TEAMS_NAME);
564
+ });
565
+
566
+ test("a personal chat is the other person's name whatever the count", () => {
567
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
568
+ chatType: "personal",
569
+ memberNames: ["Jane Doe"],
570
+ memberCount: 3,
571
+ });
572
+
573
+ expect(name).toBe("Jane Doe");
574
+ });
575
+ });
576
+
577
+ describe("MicrosoftTeamsUtil.buildCapturedChat", () => {
578
+ function capture(overrides?: Partial<CaptureInput>): MicrosoftTeamsChat {
579
+ return MicrosoftTeamsUtil.buildCapturedChat({
580
+ chatId: GROUP_CHAT_ID,
581
+ chatType: "groupChat",
582
+ serviceUrl: SERVICE_URL,
583
+ roster: issueRoster(),
584
+ now: NOW,
585
+ ...(overrides || {}),
586
+ });
587
+ }
588
+
589
+ describe("first capture (nothing stored yet)", () => {
590
+ test("the issue: Graph's name is stored, with only the first three members and a count", () => {
591
+ expect(capture({ topicLookup: FOUND })).toStrictEqual({
592
+ id: GROUP_CHAT_ID,
593
+ name: TEAMS_NAME,
594
+ chatType: "groupChat",
595
+ serviceUrl: SERVICE_URL,
596
+ addedAt: NOW,
597
+ memberAadObjectIds: ISSUE_AAD_IDS,
598
+ topic: TEAMS_NAME,
599
+ memberNames: ISSUE_MEMBERS_SHOWN,
600
+ memberCount: 4,
601
+ });
602
+ });
603
+
604
+ test.each([
605
+ ["NoTopic (the chat has no name)", NO_TOPIC],
606
+ ["PermissionDenied", DENIED],
607
+ ["Unknown", UNKNOWN],
608
+ ["not asked (no project, or no Graph call)", undefined],
609
+ ])(
610
+ "Graph %s: the members name the chat and no topic is stored",
611
+ (_label: string, lookup: MicrosoftTeamsChatTopicLookup | undefined) => {
612
+ expect(capture({ topicLookup: lookup })).toStrictEqual({
613
+ id: GROUP_CHAT_ID,
614
+ name: ISSUE_MEMBER_NAME,
615
+ chatType: "groupChat",
616
+ serviceUrl: SERVICE_URL,
617
+ addedAt: NOW,
618
+ memberAadObjectIds: ISSUE_AAD_IDS,
619
+ memberNames: ISSUE_MEMBERS_SHOWN,
620
+ memberCount: 4,
621
+ });
622
+ },
623
+ );
624
+
625
+ test("a name on the activity wins over Graph's, and is stored as the topic", () => {
626
+ const chat: MicrosoftTeamsChat = capture({
627
+ activityTopic: "Ops War Room",
628
+ topicLookup: FOUND,
629
+ });
630
+
631
+ expect(chat.name).toBe("Ops War Room");
632
+ expect(chat.topic).toBe("Ops War Room");
633
+ });
634
+
635
+ test("the activity's name is trimmed", () => {
636
+ const chat: MicrosoftTeamsChat = capture({
637
+ activityTopic: " Ops War Room \n",
638
+ });
639
+
640
+ expect(chat.name).toBe("Ops War Room");
641
+ expect(chat.topic).toBe("Ops War Room");
642
+ });
643
+
644
+ test("a whitespace-only activity name is no name: Graph's is used", () => {
645
+ const chat: MicrosoftTeamsChat = capture({
646
+ activityTopic: " ",
647
+ topicLookup: FOUND,
648
+ });
649
+
650
+ expect(chat.name).toBe(TEAMS_NAME);
651
+ expect(chat.topic).toBe(TEAMS_NAME);
652
+ });
653
+
654
+ test("a long Teams name is cut for display, but stored whole as the topic", () => {
655
+ const longTopic: string =
656
+ "Payments platform — production incident bridge for the EMEA and APAC on-call rotations";
657
+
658
+ const chat: MicrosoftTeamsChat = capture({
659
+ topicLookup: {
660
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
661
+ topic: longTopic,
662
+ },
663
+ });
664
+
665
+ expect(chat.name).toBe(
666
+ "Payments platform — production incident bridge for the EMEA and APAC on-call ro…",
667
+ );
668
+ expect(chat.topic).toBe(longTopic);
669
+ });
670
+
671
+ test("only the first three names are stored, with a count of all of them", () => {
672
+ const chat: MicrosoftTeamsChat = capture({
673
+ roster: {
674
+ memberNames: [
675
+ "Alice",
676
+ "Bob",
677
+ "Carol",
678
+ "Dave",
679
+ "Erin",
680
+ "Frank",
681
+ "Grace",
682
+ ],
683
+ memberAadObjectIds: ["a", "b", "c", "d", "e", "f", "g"],
684
+ },
685
+ });
686
+
687
+ expect(chat.memberNames).toEqual(["Alice", "Bob", "Carol"]);
688
+ expect(chat.memberCount).toBe(7);
689
+ expect(chat.name).toBe("Alice, Bob, Carol + 4 more");
690
+ // The Entra ids are what match personal chats to users; all are kept.
691
+ expect(chat.memberAadObjectIds).toEqual([
692
+ "a",
693
+ "b",
694
+ "c",
695
+ "d",
696
+ "e",
697
+ "f",
698
+ "g",
699
+ ]);
700
+ });
701
+
702
+ test("blank names are neither stored nor counted", () => {
703
+ const chat: MicrosoftTeamsChat = capture({
704
+ roster: {
705
+ memberNames: ["", "Alice", " ", "Bob", "Carol", "", "Dave"],
706
+ memberAadObjectIds: ["aad-alice", "aad-bob"],
707
+ },
708
+ });
709
+
710
+ expect(chat.memberNames).toEqual(["Alice", "Bob", "Carol"]);
711
+ expect(chat.memberCount).toBe(4);
712
+ expect(chat.name).toBe("Alice, Bob, Carol + 1 more");
713
+ });
714
+
715
+ test("fewer than three members are all stored", () => {
716
+ const chat: MicrosoftTeamsChat = capture({
717
+ roster: {
718
+ memberNames: ["Alice", "Bob"],
719
+ memberAadObjectIds: ["aad-alice", "aad-bob"],
720
+ },
721
+ });
722
+
723
+ expect(chat.memberNames).toEqual(["Alice", "Bob"]);
724
+ expect(chat.memberCount).toBe(2);
725
+ expect(chat.name).toBe("Alice, Bob");
726
+ });
727
+
728
+ test("an empty roster is stored as an empty list and a count of 0 — it was read", () => {
729
+ expect(
730
+ capture({ roster: { memberNames: [], memberAadObjectIds: [] } }),
731
+ ).toStrictEqual({
732
+ id: GROUP_CHAT_ID,
733
+ name: "Group chat",
734
+ chatType: "groupChat",
735
+ serviceUrl: SERVICE_URL,
736
+ addedAt: NOW,
737
+ memberAadObjectIds: [],
738
+ memberNames: [],
739
+ memberCount: 0,
740
+ });
741
+ });
742
+
743
+ test("no roster and no name: 'Group chat', and no member keys at all (so it is retried)", () => {
744
+ const chat: MicrosoftTeamsChat = capture({ roster: null });
745
+
746
+ expect(chat).toStrictEqual({
747
+ id: GROUP_CHAT_ID,
748
+ name: "Group chat",
749
+ chatType: "groupChat",
750
+ serviceUrl: SERVICE_URL,
751
+ addedAt: NOW,
752
+ memberAadObjectIds: [],
753
+ });
754
+ expect(Object.keys(chat)).not.toContain("topic");
755
+ expect(Object.keys(chat)).not.toContain("memberNames");
756
+ expect(Object.keys(chat)).not.toContain("memberCount");
757
+ });
758
+
759
+ test("no roster but Graph's name: the name is stored, and still no member keys", () => {
760
+ expect(capture({ roster: null, topicLookup: FOUND })).toStrictEqual({
761
+ id: GROUP_CHAT_ID,
762
+ name: TEAMS_NAME,
763
+ chatType: "groupChat",
764
+ serviceUrl: SERVICE_URL,
765
+ addedAt: NOW,
766
+ memberAadObjectIds: [],
767
+ topic: TEAMS_NAME,
768
+ });
769
+ });
770
+ });
771
+
772
+ describe("re-capture (building on the stored record)", () => {
773
+ const newRoster: Roster = {
774
+ memberNames: ["Alice", "Bob", "Carol", "Dave", "Erin"],
775
+ memberAadObjectIds: ["aad-a", "aad-b", "aad-c", "aad-d", "aad-e"],
776
+ };
777
+
778
+ test.each([
779
+ ["PermissionDenied", DENIED],
780
+ ["Unknown", UNKNOWN],
781
+ ["not asked", undefined],
782
+ ])(
783
+ "Graph %s: the stored Teams name is kept, never replaced by member names",
784
+ (_label: string, lookup: MicrosoftTeamsChatTopicLookup | undefined) => {
785
+ const chat: MicrosoftTeamsChat = capture({
786
+ storedChat: buildNamedChat({ serviceUrl: OLD_SERVICE_URL }),
787
+ roster: newRoster,
788
+ topicLookup: lookup,
789
+ });
790
+
791
+ expect(chat).toStrictEqual({
792
+ id: GROUP_CHAT_ID,
793
+ name: TEAMS_NAME,
794
+ chatType: "groupChat",
795
+ serviceUrl: SERVICE_URL,
796
+ addedAt: ADDED_AT,
797
+ memberAadObjectIds: ["aad-a", "aad-b", "aad-c", "aad-d", "aad-e"],
798
+ topic: TEAMS_NAME,
799
+ memberNames: ["Alice", "Bob", "Carol"],
800
+ memberCount: 5,
801
+ });
802
+ },
803
+ );
804
+
805
+ test.each([
806
+ ["PermissionDenied", DENIED],
807
+ ["Unknown", UNKNOWN],
808
+ ["not asked", undefined],
809
+ ])(
810
+ "Graph %s and no roster: the whole stored record is kept, with the new serviceUrl",
811
+ (_label: string, lookup: MicrosoftTeamsChatTopicLookup | undefined) => {
812
+ const storedChat: MicrosoftTeamsChat = buildNamedChat({
813
+ serviceUrl: OLD_SERVICE_URL,
814
+ });
815
+
816
+ const chat: MicrosoftTeamsChat = capture({
817
+ storedChat: storedChat,
818
+ roster: null,
819
+ topicLookup: lookup,
820
+ });
821
+
822
+ expect(chat).toStrictEqual({
823
+ ...buildNamedChat(),
824
+ serviceUrl: SERVICE_URL,
825
+ });
826
+ },
827
+ );
828
+
829
+ test("a chat renamed in Teams takes the new name", () => {
830
+ const chat: MicrosoftTeamsChat = capture({
831
+ storedChat: buildNamedChat(),
832
+ topicLookup: {
833
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
834
+ topic: "Payments On-Call",
835
+ },
836
+ });
837
+
838
+ expect(chat.name).toBe("Payments On-Call");
839
+ expect(chat.topic).toBe("Payments On-Call");
840
+ });
841
+
842
+ test("a name on the activity replaces the stored one", () => {
843
+ const chat: MicrosoftTeamsChat = capture({
844
+ storedChat: buildNamedChat(),
845
+ activityTopic: "Ops War Room",
846
+ });
847
+
848
+ expect(chat.name).toBe("Ops War Room");
849
+ expect(chat.topic).toBe("Ops War Room");
850
+ });
851
+
852
+ test("NoTopic (the name was removed in Teams): the topic is dropped and the new roster names the chat", () => {
853
+ const chat: MicrosoftTeamsChat = capture({
854
+ storedChat: buildNamedChat(),
855
+ roster: newRoster,
856
+ topicLookup: NO_TOPIC,
857
+ });
858
+
859
+ expect(chat.name).toBe("Alice, Bob, Carol + 2 more");
860
+ expect(Object.keys(chat)).not.toContain("topic");
861
+ });
862
+
863
+ test("NoTopic and no roster: the stored member names and count name the chat", () => {
864
+ const chat: MicrosoftTeamsChat = capture({
865
+ storedChat: buildNamedChat(),
866
+ roster: null,
867
+ topicLookup: NO_TOPIC,
868
+ });
869
+
870
+ expect(chat).toStrictEqual({
871
+ id: GROUP_CHAT_ID,
872
+ name: ISSUE_MEMBER_NAME,
873
+ chatType: "groupChat",
874
+ serviceUrl: SERVICE_URL,
875
+ addedAt: ADDED_AT,
876
+ memberAadObjectIds: ISSUE_AAD_IDS,
877
+ memberNames: ISSUE_MEMBERS_SHOWN,
878
+ memberCount: 4,
879
+ });
880
+ });
881
+
882
+ test("NoTopic, no roster, no stored members, and the stored name was the Teams name: 'Group chat'", () => {
883
+ // The removed name must not live on as if it were still the chat's.
884
+ const storedChat: MicrosoftTeamsChat = buildLegacyChat({
885
+ name: TEAMS_NAME,
886
+ topic: TEAMS_NAME,
887
+ });
888
+
889
+ const chat: MicrosoftTeamsChat = capture({
890
+ storedChat: storedChat,
891
+ roster: null,
892
+ topicLookup: NO_TOPIC,
893
+ });
894
+
895
+ expect(chat.name).toBe("Group chat");
896
+ expect(Object.keys(chat)).not.toContain("topic");
897
+ });
898
+
899
+ test("NoTopic, no roster, no stored members, and a member-built stored name: the stored name is kept", () => {
900
+ const chat: MicrosoftTeamsChat = capture({
901
+ storedChat: buildLegacyChat(),
902
+ roster: null,
903
+ topicLookup: NO_TOPIC,
904
+ });
905
+
906
+ expect(chat.name).toBe("Alice, Bob, Carol + 4 more");
907
+ expect(Object.keys(chat)).not.toContain("topic");
908
+ });
909
+
910
+ test.each([
911
+ ["PermissionDenied", DENIED],
912
+ ["Unknown", UNKNOWN],
913
+ ["not asked", undefined],
914
+ ["NoTopic", NO_TOPIC],
915
+ ])(
916
+ "a legacy record, no roster, no name (Graph %s): the stored name is kept, never 'Group chat'",
917
+ (_label: string, lookup: MicrosoftTeamsChatTopicLookup | undefined) => {
918
+ const chat: MicrosoftTeamsChat = capture({
919
+ storedChat: buildLegacyChat({ name: "Alice, Bob" }),
920
+ roster: null,
921
+ topicLookup: lookup,
922
+ });
923
+
924
+ expect(chat.name).toBe("Alice, Bob");
925
+ // Still no count: the next message re-captures it and tries again.
926
+ expect(Object.keys(chat)).not.toContain("memberCount");
927
+ expect(Object.keys(chat)).not.toContain("memberNames");
928
+ expect(Object.keys(chat)).not.toContain("topic");
929
+ },
930
+ );
931
+
932
+ test("no roster: the stored member names, count and Entra ids are kept", () => {
933
+ const storedChat: MicrosoftTeamsChat = buildChat({
934
+ name: "Alice, Bob, Carol + 3 more",
935
+ memberNames: ["Alice", "Bob", "Carol"],
936
+ memberCount: 6,
937
+ memberAadObjectIds: ["aad-a", "aad-b"],
938
+ });
939
+
940
+ const chat: MicrosoftTeamsChat = capture({
941
+ storedChat: storedChat,
942
+ roster: null,
943
+ topicLookup: UNKNOWN,
944
+ });
945
+
946
+ expect(chat.memberNames).toEqual(["Alice", "Bob", "Carol"]);
947
+ expect(chat.memberCount).toBe(6);
948
+ expect(chat.memberAadObjectIds).toEqual(["aad-a", "aad-b"]);
949
+ expect(chat.name).toBe("Alice, Bob, Carol + 3 more");
950
+ });
951
+
952
+ test("a roster that was read replaces the stored members", () => {
953
+ const chat: MicrosoftTeamsChat = capture({
954
+ storedChat: buildChat(),
955
+ roster: newRoster,
956
+ topicLookup: UNKNOWN,
957
+ });
958
+
959
+ expect(chat.memberNames).toEqual(["Alice", "Bob", "Carol"]);
960
+ expect(chat.memberCount).toBe(5);
961
+ expect(chat.memberAadObjectIds).toEqual(newRoster.memberAadObjectIds);
962
+ expect(chat.name).toBe("Alice, Bob, Carol + 2 more");
963
+ });
964
+
965
+ test("a legacy record read in full gains its Teams name and member count", () => {
966
+ expect(
967
+ capture({ storedChat: buildLegacyChat(), topicLookup: FOUND }),
968
+ ).toStrictEqual({
969
+ id: GROUP_CHAT_ID,
970
+ name: TEAMS_NAME,
971
+ chatType: "groupChat",
972
+ serviceUrl: SERVICE_URL,
973
+ addedAt: ADDED_AT,
974
+ memberAadObjectIds: ISSUE_AAD_IDS,
975
+ topic: TEAMS_NAME,
976
+ memberNames: ISSUE_MEMBERS_SHOWN,
977
+ memberCount: 4,
978
+ });
979
+ });
980
+
981
+ test("the stored addedAt is kept: hearing from a chat again is not connecting it", () => {
982
+ expect(capture({ storedChat: buildChat() }).addedAt).toBe(ADDED_AT);
983
+ });
984
+
985
+ test("a stored record without addedAt gets now", () => {
986
+ const storedChat: MicrosoftTeamsChat = buildChat();
987
+ delete storedChat.addedAt;
988
+
989
+ expect(capture({ storedChat: storedChat }).addedAt).toBe(NOW);
990
+ });
991
+
992
+ test("the stored record is not mutated", () => {
993
+ const storedChat: MicrosoftTeamsChat = buildNamedChat();
994
+ const snapshot: string = JSON.stringify(storedChat);
995
+
996
+ capture({
997
+ storedChat: storedChat,
998
+ roster: newRoster,
999
+ topicLookup: NO_TOPIC,
1000
+ });
1001
+
1002
+ expect(JSON.stringify(storedChat)).toBe(snapshot);
1003
+ });
1004
+ });
1005
+
1006
+ describe("personal chats", () => {
1007
+ test("are named after the other person, with their roster stored", () => {
1008
+ expect(
1009
+ capture({
1010
+ chatId: PERSONAL_CHAT_ID,
1011
+ chatType: "personal",
1012
+ roster: {
1013
+ memberNames: ["Jane Doe"],
1014
+ memberAadObjectIds: ["aad-jane"],
1015
+ },
1016
+ }),
1017
+ ).toStrictEqual({
1018
+ id: PERSONAL_CHAT_ID,
1019
+ name: "Jane Doe",
1020
+ chatType: "personal",
1021
+ serviceUrl: SERVICE_URL,
1022
+ addedAt: NOW,
1023
+ memberAadObjectIds: ["aad-jane"],
1024
+ memberNames: ["Jane Doe"],
1025
+ memberCount: 1,
1026
+ });
1027
+ });
1028
+
1029
+ test("no roster: the stored name and Entra ids are kept (it stays a DM target)", () => {
1030
+ const chat: MicrosoftTeamsChat = capture({
1031
+ chatId: PERSONAL_CHAT_ID,
1032
+ chatType: "personal",
1033
+ roster: null,
1034
+ storedChat: buildPersonalChat({ serviceUrl: OLD_SERVICE_URL }),
1035
+ });
1036
+
1037
+ expect(chat).toStrictEqual(buildPersonalChat());
1038
+ });
1039
+
1040
+ test("no roster and nothing stored: 'Personal chat'", () => {
1041
+ const chat: MicrosoftTeamsChat = capture({
1042
+ chatId: PERSONAL_CHAT_ID,
1043
+ chatType: "personal",
1044
+ roster: null,
1045
+ });
1046
+
1047
+ expect(chat.name).toBe("Personal chat");
1048
+ expect(chat.memberAadObjectIds).toEqual([]);
1049
+ });
1050
+ });
1051
+ });
1052
+
1053
+ describe("MicrosoftTeamsUtil.getTenantChatContext", () => {
1054
+ test("reads the tenant's Microsoft Teams connections once, as root, with their chats", async () => {
1055
+ const findBySpy: jest.SpyInstance = jest
1056
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
1057
+ .mockResolvedValue([]);
1058
+
1059
+ await tenantChatContext()({ tenantId: TENANT_ID, chatId: GROUP_CHAT_ID });
1060
+
1061
+ expect(findBySpy).toHaveBeenCalledTimes(1);
1062
+ expect(findBySpy).toHaveBeenCalledWith({
1063
+ query: {
1064
+ workspaceType: WorkspaceType.MicrosoftTeams,
1065
+ workspaceProjectId: TENANT_ID,
1066
+ },
1067
+ select: { _id: true, projectId: true, miscData: true },
1068
+ limit: LIMIT_MAX,
1069
+ skip: 0,
1070
+ props: { isRoot: true },
1071
+ });
1072
+ });
1073
+
1074
+ test("the project is the first connection that has one", async () => {
1075
+ const projectId: ObjectID = ObjectID.generate();
1076
+ jest
1077
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
1078
+ .mockResolvedValue([
1079
+ buildAuthRow({}),
1080
+ buildAuthRow({ projectId: projectId }),
1081
+ buildAuthRow({ projectId: ObjectID.generate() }),
1082
+ ]);
1083
+
1084
+ const context: TenantChatContext = await tenantChatContext()({
1085
+ tenantId: TENANT_ID,
1086
+ chatId: GROUP_CHAT_ID,
1087
+ });
1088
+
1089
+ expect(context.projectId).toBe(projectId);
1090
+ });
1091
+
1092
+ test("the stored chat is the first connection's copy of it, and may come from another row than the project", async () => {
1093
+ const projectId: ObjectID = ObjectID.generate();
1094
+ const firstCopy: MicrosoftTeamsChat = buildNamedChat();
1095
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
1096
+ buildAuthRow({ projectId: projectId }),
1097
+ buildAuthRow({ withoutMiscData: true }),
1098
+ buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: firstCopy } }),
1099
+ buildAuthRow({
1100
+ projectId: ObjectID.generate(),
1101
+ availableChats: {
1102
+ [GROUP_CHAT_ID]: buildChat({ name: "A later copy" }),
1103
+ },
1104
+ }),
1105
+ ]);
1106
+
1107
+ const context: TenantChatContext = await tenantChatContext()({
1108
+ tenantId: TENANT_ID,
1109
+ chatId: GROUP_CHAT_ID,
1110
+ });
1111
+
1112
+ expect(context.projectId).toBe(projectId);
1113
+ expect(context.storedChat).toBe(firstCopy);
1114
+ });
1115
+
1116
+ test("only this chat is returned, not another stored chat", async () => {
1117
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
1118
+ buildAuthRow({
1119
+ projectId: ObjectID.generate(),
1120
+ availableChats: {
1121
+ "19:other@thread.v2": buildChat({ id: "19:other@thread.v2" }),
1122
+ },
1123
+ }),
1124
+ ]);
1125
+
1126
+ const context: TenantChatContext = await tenantChatContext()({
1127
+ tenantId: TENANT_ID,
1128
+ chatId: GROUP_CHAT_ID,
1129
+ });
1130
+
1131
+ expect(context.storedChat).toBeUndefined();
1132
+ });
1133
+
1134
+ test("no connections: no project and no stored chat", async () => {
1135
+ jest
1136
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
1137
+ .mockResolvedValue([]);
1138
+
1139
+ await expect(
1140
+ tenantChatContext()({ tenantId: TENANT_ID, chatId: GROUP_CHAT_ID }),
1141
+ ).resolves.toEqual({ projectId: null, storedChat: undefined });
1142
+ });
1143
+
1144
+ test("a failed read throws: capturing blind would overwrite what is stored", async () => {
1145
+ jest
1146
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
1147
+ .mockRejectedValue(new Error("db down"));
1148
+
1149
+ await expect(
1150
+ tenantChatContext()({ tenantId: TENANT_ID, chatId: GROUP_CHAT_ID }),
1151
+ ).rejects.toThrow("db down");
1152
+ });
1153
+ });
1154
+
1155
+ describe("chat capture reads the group chat's name from Graph", () => {
1156
+ const projectId: ObjectID = ObjectID.generate();
1157
+ let saveSpy: jest.SpyInstance;
1158
+ let tenantContextSpy: jest.SpyInstance;
1159
+ let topicSpy: jest.SpyInstance;
1160
+
1161
+ beforeEach(() => {
1162
+ saveSpy = jest
1163
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
1164
+ .mockResolvedValue(undefined as never);
1165
+ jest
1166
+ .spyOn(MicrosoftTeamsUtil, "removeChatFromProjectAuthTokens")
1167
+ .mockResolvedValue(undefined as never);
1168
+ jest
1169
+ .spyOn(MicrosoftTeamsUtil as any, "sendWelcomeAdaptiveCard")
1170
+ .mockResolvedValue(undefined as never);
1171
+ jest.spyOn(OneUptimeDate, "getCurrentDate").mockReturnValue(new Date(NOW));
1172
+ tenantContextSpy = jest
1173
+ .spyOn(MicrosoftTeamsUtil as any, "getTenantChatContext")
1174
+ .mockResolvedValue({
1175
+ projectId: projectId,
1176
+ storedChat: undefined,
1177
+ } as never);
1178
+ topicSpy = jest.spyOn(MicrosoftTeamsUtil, "getGroupChatTopicFromGraph");
1179
+ });
1180
+
1181
+ function mockTopic(lookup: MicrosoftTeamsChatTopicLookup): void {
1182
+ topicSpy.mockResolvedValue(lookup);
1183
+ }
1184
+
1185
+ function mockStoredChat(storedChat: MicrosoftTeamsChat): void {
1186
+ tenantContextSpy.mockResolvedValue({
1187
+ projectId: projectId,
1188
+ storedChat: storedChat,
1189
+ } as never);
1190
+ }
1191
+
1192
+ function botAddedToGroupChat(conversation?: JSONObject): JSONObject {
1193
+ return {
1194
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
1195
+ conversation: conversation || {
1196
+ conversationType: "groupChat",
1197
+ id: GROUP_CHAT_ID,
1198
+ },
1199
+ channelData: { tenant: { id: TENANT_ID } },
1200
+ serviceUrl: SERVICE_URL,
1201
+ };
1202
+ }
1203
+
1204
+ async function addBot(conversation?: JSONObject): Promise<void> {
1205
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1206
+ activity: botAddedToGroupChat(conversation),
1207
+ turnContext: buildTurnContext(),
1208
+ });
1209
+ }
1210
+
1211
+ async function installationUpdate(action: string): Promise<void> {
1212
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1213
+ activity: {
1214
+ action: action,
1215
+ conversation: { conversationType: "groupChat", id: GROUP_CHAT_ID },
1216
+ channelData: { tenant: { id: TENANT_ID } },
1217
+ serviceUrl: SERVICE_URL,
1218
+ },
1219
+ turnContext: buildTurnContext(),
1220
+ });
1221
+ }
1222
+
1223
+ function savedChat(): MicrosoftTeamsChat {
1224
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1225
+ return (saveSpy.mock.calls[0]![0] as { chat: MicrosoftTeamsChat }).chat;
1226
+ }
1227
+
1228
+ test("the issue: a named group chat is saved under its name, with three member names and a count", async () => {
1229
+ mockIssueMembers();
1230
+ mockTopic(FOUND);
1231
+
1232
+ await addBot();
1233
+
1234
+ expect(savedChat()).toStrictEqual({
1235
+ id: GROUP_CHAT_ID,
1236
+ name: TEAMS_NAME,
1237
+ chatType: "groupChat",
1238
+ serviceUrl: SERVICE_URL,
1239
+ addedAt: NOW,
1240
+ memberAadObjectIds: ISSUE_AAD_IDS,
1241
+ topic: TEAMS_NAME,
1242
+ memberNames: ISSUE_MEMBERS_SHOWN,
1243
+ memberCount: 4,
1244
+ });
1245
+ expect(savedChat().name).not.toContain("Contractor");
1246
+ expect(saveSpy.mock.calls[0]![0]).toMatchObject({ tenantId: TENANT_ID });
1247
+ });
1248
+
1249
+ test("when the stored chat cannot be read, nothing is saved and Graph is not asked", async () => {
1250
+ /*
1251
+ * Saving without knowing what is stored would replace a chat's real name
1252
+ * with whatever this activity could supply (e.g. member names).
1253
+ */
1254
+ mockIssueMembers();
1255
+ tenantContextSpy.mockRejectedValue(new Error("db down"));
1256
+ const errorSpy: jest.SpyInstance = jest
1257
+ .spyOn(logger, "error")
1258
+ .mockImplementation(() => {
1259
+ return undefined;
1260
+ });
1261
+
1262
+ await addBot();
1263
+
1264
+ expect(saveSpy).not.toHaveBeenCalled();
1265
+ expect(topicSpy).not.toHaveBeenCalled();
1266
+ expect(errorSpy).toHaveBeenCalled();
1267
+ });
1268
+
1269
+ test("the tenant's context is read for this chat, and Graph is asked with its project", async () => {
1270
+ mockIssueMembers();
1271
+ mockTopic(FOUND);
1272
+
1273
+ await addBot();
1274
+
1275
+ expect(tenantContextSpy).toHaveBeenCalledTimes(1);
1276
+ expect(tenantContextSpy).toHaveBeenCalledWith({
1277
+ tenantId: TENANT_ID,
1278
+ chatId: GROUP_CHAT_ID,
1279
+ });
1280
+ expect(topicSpy).toHaveBeenCalledTimes(1);
1281
+ // One chat, so no token is handed in: the lookup fetches its own.
1282
+ expect(topicSpy.mock.calls[0]![0]).toStrictEqual({
1283
+ projectId: projectId,
1284
+ chatId: GROUP_CHAT_ID,
1285
+ });
1286
+ });
1287
+
1288
+ test("the bot and blank names are left out of the stored members", async () => {
1289
+ mockMembers([
1290
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
1291
+ { id: "user-1", name: "Alice", aadObjectId: "aad-1" },
1292
+ { id: "user-2", name: " " },
1293
+ { id: "user-3", name: "Bob", aadObjectId: "aad-3" },
1294
+ ]);
1295
+ mockTopic(FOUND);
1296
+
1297
+ await addBot();
1298
+
1299
+ const chat: MicrosoftTeamsChat = savedChat();
1300
+ expect(chat.memberNames).toEqual(["Alice", "Bob"]);
1301
+ expect(chat.memberCount).toBe(2);
1302
+ expect(chat.memberAadObjectIds).toEqual(["aad-1", "aad-3"]);
1303
+ });
1304
+
1305
+ test.each([
1306
+ ["NoTopic", NO_TOPIC],
1307
+ ["PermissionDenied", DENIED],
1308
+ ["Unknown", UNKNOWN],
1309
+ ])(
1310
+ "a first capture with Graph %s is still saved, under its members' names",
1311
+ async (_label: string, lookup: MicrosoftTeamsChatTopicLookup) => {
1312
+ mockIssueMembers();
1313
+ mockTopic(lookup);
1314
+
1315
+ await addBot();
1316
+
1317
+ const chat: MicrosoftTeamsChat = savedChat();
1318
+ expect(chat.name).toBe(ISSUE_MEMBER_NAME);
1319
+ expect(chat.memberNames).toEqual(ISSUE_MEMBERS_SHOWN);
1320
+ expect(chat.memberCount).toBe(4);
1321
+ expect(Object.keys(chat)).not.toContain("topic");
1322
+ },
1323
+ );
1324
+
1325
+ test.each([
1326
+ ["Unknown", UNKNOWN],
1327
+ ["PermissionDenied", DENIED],
1328
+ ])(
1329
+ "re-adding the bot to a chat whose Teams name is stored keeps that name when Graph is %s",
1330
+ async (_label: string, lookup: MicrosoftTeamsChatTopicLookup) => {
1331
+ mockIssueMembers();
1332
+ mockStoredChat(buildNamedChat({ serviceUrl: OLD_SERVICE_URL }));
1333
+ mockTopic(lookup);
1334
+
1335
+ await addBot();
1336
+
1337
+ const chat: MicrosoftTeamsChat = savedChat();
1338
+ expect(chat.name).toBe(TEAMS_NAME);
1339
+ expect(chat.topic).toBe(TEAMS_NAME);
1340
+ expect(chat.serviceUrl).toBe(SERVICE_URL);
1341
+ },
1342
+ );
1343
+
1344
+ test("a re-capture keeps the date the chat was first connected", async () => {
1345
+ mockIssueMembers();
1346
+ mockStoredChat(buildChat({ addedAt: ADDED_AT }));
1347
+ mockTopic(UNKNOWN);
1348
+
1349
+ await addBot();
1350
+
1351
+ expect(savedChat().addedAt).toBe(ADDED_AT);
1352
+ });
1353
+
1354
+ test("a name on the activity itself is used as is, and Graph is not called", async () => {
1355
+ mockIssueMembers();
1356
+
1357
+ await addBot({
1358
+ conversationType: "groupChat",
1359
+ id: GROUP_CHAT_ID,
1360
+ name: "Ops War Room",
1361
+ });
1362
+
1363
+ const chat: MicrosoftTeamsChat = savedChat();
1364
+ expect(chat.name).toBe("Ops War Room");
1365
+ expect(chat.topic).toBe("Ops War Room");
1366
+ expect(topicSpy).not.toHaveBeenCalled();
1367
+ });
1368
+
1369
+ test("a whitespace-only name on the activity is ignored and Graph is asked", async () => {
1370
+ mockIssueMembers();
1371
+ mockTopic(FOUND);
1372
+
1373
+ await addBot({
1374
+ conversationType: "groupChat",
1375
+ id: GROUP_CHAT_ID,
1376
+ name: " ",
1377
+ });
1378
+
1379
+ expect(savedChat().name).toBe(TEAMS_NAME);
1380
+ expect(topicSpy).toHaveBeenCalledTimes(1);
1381
+ });
1382
+
1383
+ test("a name on the activity that is not a string is ignored and Graph is asked", async () => {
1384
+ mockIssueMembers();
1385
+ mockTopic(FOUND);
1386
+
1387
+ await addBot({
1388
+ conversationType: "groupChat",
1389
+ id: GROUP_CHAT_ID,
1390
+ name: 42,
1391
+ });
1392
+
1393
+ expect(savedChat().name).toBe(TEAMS_NAME);
1394
+ expect(topicSpy).toHaveBeenCalledTimes(1);
1395
+ });
1396
+
1397
+ test("personal chats are never looked up — they have no name, and keep the other person's", async () => {
1398
+ mockMembers([
1399
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
1400
+ { id: "user-1", name: "Jane Doe", aadObjectId: "aad-jane" },
1401
+ ]);
1402
+
1403
+ await addBot({ conversationType: "personal", id: PERSONAL_CHAT_ID });
1404
+
1405
+ const chat: MicrosoftTeamsChat = savedChat();
1406
+ expect(chat.chatType).toBe("personal");
1407
+ expect(chat.name).toBe("Jane Doe");
1408
+ expect(chat.memberAadObjectIds).toEqual(["aad-jane"]);
1409
+ expect(topicSpy).not.toHaveBeenCalled();
1410
+ });
1411
+
1412
+ test("channels are still ignored: no read, no lookup, no save", async () => {
1413
+ mockIssueMembers();
1414
+
1415
+ await addBot({ conversationType: "channel", id: "19:chan@thread.tacv2" });
1416
+
1417
+ expect(saveSpy).not.toHaveBeenCalled();
1418
+ expect(tenantContextSpy).not.toHaveBeenCalled();
1419
+ expect(topicSpy).not.toHaveBeenCalled();
1420
+ });
1421
+
1422
+ test("no project connected to the tenant: Graph is skipped and members name the chat", async () => {
1423
+ mockIssueMembers();
1424
+ tenantContextSpy.mockResolvedValue({
1425
+ projectId: null,
1426
+ storedChat: undefined,
1427
+ } as never);
1428
+
1429
+ await addBot();
1430
+
1431
+ expect(topicSpy).not.toHaveBeenCalled();
1432
+ expect(savedChat().name).toBe(ISSUE_MEMBER_NAME);
1433
+ });
1434
+
1435
+ test("a failed roster fetch still gets the Graph name, and leaves the member count unset", async () => {
1436
+ mockRosterFailure();
1437
+ mockTopic(FOUND);
1438
+
1439
+ await addBot();
1440
+
1441
+ const chat: MicrosoftTeamsChat = savedChat();
1442
+ expect(chat.name).toBe(TEAMS_NAME);
1443
+ expect(Object.keys(chat)).not.toContain("memberNames");
1444
+ expect(Object.keys(chat)).not.toContain("memberCount");
1445
+ });
1446
+
1447
+ test("a failed roster fetch and no Graph name falls back to 'Group chat'", async () => {
1448
+ mockRosterFailure();
1449
+ mockTopic(NO_TOPIC);
1450
+
1451
+ await addBot();
1452
+
1453
+ expect(savedChat().name).toBe("Group chat");
1454
+ });
1455
+
1456
+ test("a long Graph name is cut to 80 characters like any other name", async () => {
1457
+ mockIssueMembers();
1458
+ const longTopic: string =
1459
+ "Payments platform — production incident bridge for the EMEA and APAC on-call rotations";
1460
+ expect(longTopic.length).toBeGreaterThan(80);
1461
+ mockTopic({
1462
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
1463
+ topic: longTopic,
1464
+ });
1465
+
1466
+ await addBot();
1467
+
1468
+ const chat: MicrosoftTeamsChat = savedChat();
1469
+ expect(chat.name).toHaveLength(80);
1470
+ expect(chat.name.endsWith("…")).toBe(true);
1471
+ expect(chat.name.startsWith("Payments platform — production")).toBe(true);
1472
+ expect(chat.topic).toBe(longTopic);
1473
+ });
1474
+
1475
+ test("group chats with the same members are told apart by their names", async () => {
1476
+ mockIssueMembers();
1477
+ topicSpy
1478
+ .mockResolvedValueOnce({
1479
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
1480
+ topic: "Payments on-call",
1481
+ })
1482
+ .mockResolvedValueOnce({
1483
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
1484
+ topic: "Search on-call",
1485
+ });
1486
+
1487
+ await addBot({ conversationType: "groupChat", id: "19:one@thread.v2" });
1488
+ await addBot({ conversationType: "groupChat", id: "19:two@thread.v2" });
1489
+
1490
+ const names: Array<string> = saveSpy.mock.calls.map(
1491
+ (call: Array<unknown>) => {
1492
+ return (call[0] as { chat: MicrosoftTeamsChat }).chat.name;
1493
+ },
1494
+ );
1495
+ expect(names).toEqual(["Payments on-call", "Search on-call"]);
1496
+ });
1497
+
1498
+ test("installationUpdate 'add' in a group chat also stores the Graph name", async () => {
1499
+ mockIssueMembers();
1500
+ mockTopic(FOUND);
1501
+
1502
+ await installationUpdate("add");
1503
+
1504
+ expect(savedChat().name).toBe(TEAMS_NAME);
1505
+ expect(savedChat().memberCount).toBe(4);
1506
+ });
1507
+
1508
+ test("installationUpdate 'add-upgrade' (an upgrade that adds the bot) captures the chat like 'add'", async () => {
1509
+ mockIssueMembers();
1510
+ mockStoredChat(buildLegacyChat());
1511
+ mockTopic(FOUND);
1512
+
1513
+ await installationUpdate("add-upgrade");
1514
+
1515
+ const chat: MicrosoftTeamsChat = savedChat();
1516
+ expect(chat.name).toBe(TEAMS_NAME);
1517
+ expect(chat.topic).toBe(TEAMS_NAME);
1518
+ expect(chat.addedAt).toBe(ADDED_AT);
1519
+ });
1520
+
1521
+ test("the tenant falls back to conversation.tenantId", async () => {
1522
+ mockIssueMembers();
1523
+ mockTopic(FOUND);
1524
+
1525
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1526
+ activity: {
1527
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
1528
+ conversation: {
1529
+ conversationType: "groupChat",
1530
+ id: GROUP_CHAT_ID,
1531
+ tenantId: "tenant-from-conversation",
1532
+ },
1533
+ channelData: {},
1534
+ serviceUrl: SERVICE_URL,
1535
+ },
1536
+ turnContext: buildTurnContext(),
1537
+ });
1538
+
1539
+ expect(tenantContextSpy).toHaveBeenCalledWith({
1540
+ tenantId: "tenant-from-conversation",
1541
+ chatId: GROUP_CHAT_ID,
1542
+ });
1543
+ expect(savedChat().name).toBe(TEAMS_NAME);
1544
+ });
1545
+
1546
+ test("end to end through the real tenant read: the stored Teams name survives a Graph refusal", async () => {
1547
+ tenantContextSpy.mockRestore();
1548
+ mockIssueMembers();
1549
+ mockTopic(DENIED);
1550
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
1551
+ buildAuthRow({
1552
+ projectId: projectId,
1553
+ workspaceProjectId: TENANT_ID,
1554
+ availableChats: { [GROUP_CHAT_ID]: buildNamedChat() },
1555
+ }),
1556
+ ]);
1557
+
1558
+ await addBot();
1559
+
1560
+ expect(topicSpy.mock.calls[0]![0]).toMatchObject({ projectId: projectId });
1561
+ const chat: MicrosoftTeamsChat = savedChat();
1562
+ expect(chat.name).toBe(TEAMS_NAME);
1563
+ expect(chat.topic).toBe(TEAMS_NAME);
1564
+ expect(chat.addedAt).toBe(ADDED_AT);
1565
+ });
1566
+ });
1567
+
1568
+ describe("message backfill re-captures group chats stored before names were read", () => {
1569
+ const projectId: ObjectID = ObjectID.generate();
1570
+ let saveSpy: jest.SpyInstance;
1571
+ let topicSpy: jest.SpyInstance;
1572
+ let findBySpy: jest.SpyInstance;
1573
+
1574
+ function groupChatMessage(): JSONObject {
1575
+ // No @mention, so the handler returns right after the backfill.
1576
+ return {
1577
+ type: "message",
1578
+ text: "hello there",
1579
+ from: { id: "user-0", name: ISSUE_MEMBERS[0] },
1580
+ recipient: { id: BOT_RECIPIENT_ID },
1581
+ conversation: { conversationType: "groupChat", id: GROUP_CHAT_ID },
1582
+ channelData: { tenant: { id: TENANT_ID } },
1583
+ serviceUrl: SERVICE_URL,
1584
+ entities: [],
1585
+ };
1586
+ }
1587
+
1588
+ async function sendMessage(): Promise<void> {
1589
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
1590
+ activity: groupChatMessage(),
1591
+ turnContext: buildTurnContext(),
1592
+ });
1593
+ }
1594
+
1595
+ function storeChat(chat: MicrosoftTeamsChat): void {
1596
+ // Both the staleness check and the tenant read go through findBy.
1597
+ findBySpy.mockResolvedValue([
1598
+ buildAuthRow({
1599
+ projectId: projectId,
1600
+ workspaceProjectId: TENANT_ID,
1601
+ availableChats: { [chat.id]: chat },
1602
+ }),
1603
+ ]);
1604
+ }
1605
+
1606
+ function savedChats(): Array<MicrosoftTeamsChat> {
1607
+ return saveSpy.mock.calls.map((call: Array<unknown>) => {
1608
+ return (call[0] as { chat: MicrosoftTeamsChat }).chat;
1609
+ });
1610
+ }
1611
+
1612
+ beforeEach(() => {
1613
+ saveSpy = jest
1614
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
1615
+ .mockResolvedValue(undefined as never);
1616
+ findBySpy = jest.spyOn(WorkspaceProjectAuthTokenService, "findBy");
1617
+ topicSpy = jest
1618
+ .spyOn(MicrosoftTeamsUtil, "getGroupChatTopicFromGraph")
1619
+ .mockResolvedValue(FOUND);
1620
+ mockIssueMembers();
1621
+ });
1622
+
1623
+ test("a legacy record (no member count) is re-captured with its Teams name and a count", async () => {
1624
+ storeChat(buildLegacyChat());
1625
+
1626
+ await sendMessage();
1627
+
1628
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1629
+ const chat: MicrosoftTeamsChat = savedChats()[0]!;
1630
+ expect(chat.name).toBe(TEAMS_NAME);
1631
+ expect(chat.topic).toBe(TEAMS_NAME);
1632
+ expect(chat.memberNames).toEqual(ISSUE_MEMBERS_SHOWN);
1633
+ expect(chat.memberCount).toBe(4);
1634
+ expect(chat.addedAt).toBe(ADDED_AT);
1635
+ expect(topicSpy).toHaveBeenCalledWith({
1636
+ projectId: projectId,
1637
+ chatId: GROUP_CHAT_ID,
1638
+ });
1639
+ });
1640
+
1641
+ test("a legacy record, a failed roster and a Graph 403: the legacy name is kept and the count stays unset, so the next message retries", async () => {
1642
+ topicSpy.mockRestore();
1643
+ const legacyChat: MicrosoftTeamsChat = buildLegacyChat();
1644
+ storeChat(legacyChat);
1645
+ mockRosterFailure();
1646
+ apiGetSpy.mockResolvedValue(
1647
+ graphError(403, {
1648
+ error: {
1649
+ code: "Forbidden",
1650
+ message:
1651
+ "Missing role permissions on the request. API requires one of 'ChatSettings.Read.Chat, Chat.ReadBasic.WhereInstalled'.",
1652
+ },
1653
+ }),
1654
+ );
1655
+
1656
+ await sendMessage();
1657
+
1658
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
1659
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1660
+ const firstTry: MicrosoftTeamsChat = savedChats()[0]!;
1661
+ expect(firstTry.name).toBe("Alice, Bob, Carol + 4 more");
1662
+ expect(firstTry.addedAt).toBe(ADDED_AT);
1663
+ expect(Object.keys(firstTry)).not.toContain("memberCount");
1664
+ expect(Object.keys(firstTry)).not.toContain("topic");
1665
+
1666
+ // What was saved is still stale, so it is not the end of the story.
1667
+ storeChat(firstTry);
1668
+ await expect(
1669
+ isChatCapturedForTenant({
1670
+ tenantId: TENANT_ID,
1671
+ chatId: GROUP_CHAT_ID,
1672
+ serviceUrl: SERVICE_URL,
1673
+ }),
1674
+ ).resolves.toBe(false);
1675
+
1676
+ // The next message, with the roster and the permission back, completes it.
1677
+ mockIssueMembers();
1678
+ apiGetSpy.mockResolvedValue(
1679
+ graphOk({ id: GROUP_CHAT_ID, topic: TEAMS_NAME }),
1680
+ );
1681
+
1682
+ await sendMessage();
1683
+
1684
+ expect(saveSpy).toHaveBeenCalledTimes(2);
1685
+ const secondTry: MicrosoftTeamsChat = savedChats()[1]!;
1686
+ expect(secondTry.name).toBe(TEAMS_NAME);
1687
+ expect(secondTry.memberCount).toBe(4);
1688
+ expect(secondTry.addedAt).toBe(ADDED_AT);
1689
+ });
1690
+
1691
+ test("a record captured by this version is left alone: no roster, Graph or write", async () => {
1692
+ storeChat(buildNamedChat());
1693
+
1694
+ await sendMessage();
1695
+
1696
+ expect(saveSpy).not.toHaveBeenCalled();
1697
+ expect(topicSpy).not.toHaveBeenCalled();
1698
+ expect(TeamsInfo.getPagedMembers).not.toHaveBeenCalled();
1699
+ });
1700
+
1701
+ test("a record whose roster was read empty (count 0) is not re-captured on every message", async () => {
1702
+ storeChat(
1703
+ buildChat({ name: "Group chat", memberNames: [], memberCount: 0 }),
1704
+ );
1705
+
1706
+ await sendMessage();
1707
+
1708
+ expect(saveSpy).not.toHaveBeenCalled();
1709
+ expect(topicSpy).not.toHaveBeenCalled();
1710
+ });
1711
+
1712
+ test("a moved serviceUrl re-captures a named chat without losing its name when Graph fails", async () => {
1713
+ storeChat(buildNamedChat({ serviceUrl: OLD_SERVICE_URL }));
1714
+ topicSpy.mockResolvedValue(UNKNOWN);
1715
+
1716
+ await sendMessage();
1717
+
1718
+ const chat: MicrosoftTeamsChat = savedChats()[0]!;
1719
+ expect(chat.serviceUrl).toBe(SERVICE_URL);
1720
+ expect(chat.name).toBe(TEAMS_NAME);
1721
+ expect(chat.topic).toBe(TEAMS_NAME);
1722
+ });
1723
+ });
1724
+
1725
+ describe("MicrosoftTeamsUtil.isChatCapturedForTenant — group chat member count", () => {
1726
+ function mockRows(chats: Array<MicrosoftTeamsChat | undefined>): void {
1727
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue(
1728
+ chats.map((chat: MicrosoftTeamsChat | undefined) => {
1729
+ return buildAuthRow({
1730
+ availableChats: chat ? { [chat.id]: chat } : {},
1731
+ });
1732
+ }),
1733
+ );
1734
+ }
1735
+
1736
+ async function isCaptured(chatId: string): Promise<boolean> {
1737
+ return isChatCapturedForTenant({
1738
+ tenantId: TENANT_ID,
1739
+ chatId: chatId,
1740
+ serviceUrl: SERVICE_URL,
1741
+ });
1742
+ }
1743
+
1744
+ test("a group chat without a member count is stale (captured before names were read)", async () => {
1745
+ mockRows([buildLegacyChat()]);
1746
+
1747
+ await expect(isCaptured(GROUP_CHAT_ID)).resolves.toBe(false);
1748
+ });
1749
+
1750
+ test("member names without a count are still stale — the count is what marks a full capture", async () => {
1751
+ const chat: MicrosoftTeamsChat = buildChat();
1752
+ delete chat.memberCount;
1753
+ mockRows([chat]);
1754
+
1755
+ await expect(isCaptured(GROUP_CHAT_ID)).resolves.toBe(false);
1756
+ });
1757
+
1758
+ test("a group chat with a member count is captured", async () => {
1759
+ mockRows([buildChat()]);
1760
+
1761
+ await expect(isCaptured(GROUP_CHAT_ID)).resolves.toBe(true);
1762
+ });
1763
+
1764
+ test("a member count of 0 is captured (no re-capture loop for a chat with no named members)", async () => {
1765
+ mockRows([buildChat({ memberNames: [], memberCount: 0 })]);
1766
+
1767
+ await expect(isCaptured(GROUP_CHAT_ID)).resolves.toBe(true);
1768
+ });
1769
+
1770
+ test("the count rule is for group chats only — a personal chat without one is captured", async () => {
1771
+ const personalChat: MicrosoftTeamsChat = buildPersonalChat();
1772
+ delete personalChat.memberNames;
1773
+ delete personalChat.memberCount;
1774
+ mockRows([personalChat]);
1775
+
1776
+ await expect(isCaptured(PERSONAL_CHAT_ID)).resolves.toBe(true);
1777
+ });
1778
+
1779
+ test("one legacy row among several complete rows makes the chat stale", async () => {
1780
+ mockRows([buildChat(), buildLegacyChat(), buildChat()]);
1781
+
1782
+ await expect(isCaptured(GROUP_CHAT_ID)).resolves.toBe(false);
1783
+ });
1784
+ });
1785
+
1786
+ describe("MicrosoftTeamsUtil.getGroupChatTopicFromGraph", () => {
1787
+ const projectId: ObjectID = ObjectID.generate();
1788
+ let warnSpy: jest.SpyInstance;
1789
+
1790
+ beforeEach(() => {
1791
+ warnSpy = jest.spyOn(logger, "warn").mockImplementation((): void => {
1792
+ return;
1793
+ });
1794
+ });
1795
+
1796
+ async function lookup(data?: {
1797
+ chatId?: string | undefined;
1798
+ accessToken?: string | undefined;
1799
+ }): Promise<MicrosoftTeamsChatTopicLookup> {
1800
+ const chatId: string | undefined = data?.chatId;
1801
+
1802
+ return MicrosoftTeamsUtil.getGroupChatTopicFromGraph({
1803
+ projectId: projectId,
1804
+ chatId: chatId === undefined ? GROUP_CHAT_ID : chatId,
1805
+ accessToken: data?.accessToken,
1806
+ });
1807
+ }
1808
+
1809
+ function requestUrl(): string {
1810
+ const args: { url: { toString: () => string } } =
1811
+ apiGetSpy.mock.calls[0]![0];
1812
+ return args.url.toString();
1813
+ }
1814
+
1815
+ function requestHeaders(): JSONObject {
1816
+ const args: { headers: JSONObject } = apiGetSpy.mock.calls[0]![0];
1817
+ return args.headers;
1818
+ }
1819
+
1820
+ test("a named chat comes back as Found with its topic", async () => {
1821
+ apiGetSpy.mockResolvedValue(
1822
+ graphOk({ id: GROUP_CHAT_ID, topic: TEAMS_NAME, chatType: "group" }),
1823
+ );
1824
+
1825
+ await expect(lookup()).resolves.toEqual({
1826
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
1827
+ topic: TEAMS_NAME,
1828
+ });
1829
+ });
1830
+
1831
+ test("calls GET /v1.0/chats/{id} with the chat id URL-encoded, and no $select", async () => {
1832
+ apiGetSpy.mockResolvedValue(graphOk({ topic: TEAMS_NAME }));
1833
+
1834
+ await lookup();
1835
+
1836
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
1837
+ expect(requestUrl()).toBe(
1838
+ `https://graph.microsoft.com/v1.0/chats/${ENCODED_GROUP_CHAT_ID}`,
1839
+ );
1840
+ expect(requestUrl()).not.toContain("$select");
1841
+ expect(requestUrl()).not.toContain("?");
1842
+ });
1843
+
1844
+ test("the lookup has its own timeout, so a stalled read cannot hold a refresh", async () => {
1845
+ apiGetSpy.mockResolvedValue(graphOk({ topic: TEAMS_NAME }));
1846
+
1847
+ await lookup();
1848
+
1849
+ const options: { timeout?: number } | undefined =
1850
+ apiGetSpy.mock.calls[0]![0].options;
1851
+ expect(options?.timeout).toBe(15000);
1852
+ });
1853
+
1854
+ test("uses the access token it is given, without fetching one", async () => {
1855
+ apiGetSpy.mockResolvedValue(graphOk({ topic: TEAMS_NAME }));
1856
+
1857
+ await lookup({ accessToken: "token-from-refresh" });
1858
+
1859
+ expect(tokenSpy).not.toHaveBeenCalled();
1860
+ expect(requestHeaders()["Authorization"]).toBe("Bearer token-from-refresh");
1861
+ });
1862
+
1863
+ test("without a token, fetches the project's app-only Graph token", async () => {
1864
+ apiGetSpy.mockResolvedValue(graphOk({ topic: TEAMS_NAME }));
1865
+
1866
+ await lookup();
1867
+
1868
+ expect(tokenSpy).toHaveBeenCalledTimes(1);
1869
+ expect(tokenSpy).toHaveBeenCalledWith({
1870
+ authToken: "",
1871
+ projectId: projectId,
1872
+ });
1873
+ expect(requestHeaders()["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`);
1874
+ });
1875
+
1876
+ test("an empty token counts as none: the project's token is fetched", async () => {
1877
+ apiGetSpy.mockResolvedValue(graphOk({ topic: TEAMS_NAME }));
1878
+
1879
+ await lookup({ accessToken: "" });
1880
+
1881
+ expect(tokenSpy).toHaveBeenCalledTimes(1);
1882
+ expect(requestHeaders()["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`);
1883
+ });
1884
+
1885
+ test("the topic is trimmed", async () => {
1886
+ apiGetSpy.mockResolvedValue(graphOk({ topic: " Platform On-Call \n" }));
1887
+
1888
+ await expect(lookup()).resolves.toEqual({
1889
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
1890
+ topic: TEAMS_NAME,
1891
+ });
1892
+ });
1893
+
1894
+ test("a topic is returned whole — truncation is the display name's job", async () => {
1895
+ const longTopic: string = "Incident bridge ".repeat(12).trim();
1896
+ apiGetSpy.mockResolvedValue(graphOk({ topic: longTopic }));
1897
+
1898
+ const result: MicrosoftTeamsChatTopicLookup = await lookup();
1899
+ expect(result.topic).toBe(longTopic);
1900
+ });
1901
+
1902
+ test.each([
1903
+ ["null (Graph's answer for an unnamed group chat)", null],
1904
+ ["an empty string", ""],
1905
+ ["whitespace only", " "],
1906
+ ["a number", 42],
1907
+ ["an object", { value: TEAMS_NAME }],
1908
+ ])("topic that is %s is NoTopic", async (_label: string, topic: unknown) => {
1909
+ apiGetSpy.mockResolvedValue(graphOk({ id: GROUP_CHAT_ID, topic } as any));
1910
+
1911
+ await expect(lookup()).resolves.toEqual({
1912
+ status: MicrosoftTeamsChatTopicLookupStatus.NoTopic,
1913
+ });
1914
+ });
1915
+
1916
+ test("a response without a topic field is NoTopic", async () => {
1917
+ apiGetSpy.mockResolvedValue(graphOk({ id: GROUP_CHAT_ID }));
1918
+
1919
+ await expect(lookup()).resolves.toEqual({
1920
+ status: MicrosoftTeamsChatTopicLookupStatus.NoTopic,
1921
+ });
1922
+ });
1923
+
1924
+ test("403 (the chat has not granted ChatSettings.Read.Chat) is PermissionDenied", async () => {
1925
+ apiGetSpy.mockResolvedValue(
1926
+ graphError(403, {
1927
+ error: {
1928
+ code: "Forbidden",
1929
+ message:
1930
+ "Missing role permissions on the request. API requires one of 'ChatSettings.Read.Chat, Chat.ReadBasic.WhereInstalled'.",
1931
+ },
1932
+ }),
1933
+ );
1934
+
1935
+ await expect(lookup()).resolves.toEqual({
1936
+ status: MicrosoftTeamsChatTopicLookupStatus.PermissionDenied,
1937
+ });
1938
+ expect(warnSpy).not.toHaveBeenCalled();
1939
+ });
1940
+
1941
+ test("Graph's Authorization_RequestDenied code is PermissionDenied whatever the status", async () => {
1942
+ // Graph's body for this error: a code and no message of its own.
1943
+ const response: HTTPErrorResponse = graphError(400, {
1944
+ error: { code: "Authorization_RequestDenied" },
1945
+ });
1946
+ // What the real getter derives from that body, which is what is matched.
1947
+ expect(response.message).toContain("Authorization_RequestDenied");
1948
+ apiGetSpy.mockResolvedValue(response);
1949
+
1950
+ await expect(lookup()).resolves.toEqual({
1951
+ status: MicrosoftTeamsChatTopicLookupStatus.PermissionDenied,
1952
+ });
1953
+ });
1954
+
1955
+ test("Graph's 'Insufficient privileges' message is PermissionDenied whatever the status", async () => {
1956
+ const response: HTTPErrorResponse = graphError(400, {
1957
+ error: {
1958
+ code: "Authorization_RequestDenied",
1959
+ message: "Insufficient privileges to complete the operation.",
1960
+ },
1961
+ });
1962
+ expect(response.message).toBe(
1963
+ "Insufficient privileges to complete the operation.",
1964
+ );
1965
+ apiGetSpy.mockResolvedValue(response);
1966
+
1967
+ await expect(lookup()).resolves.toEqual({
1968
+ status: MicrosoftTeamsChatTopicLookupStatus.PermissionDenied,
1969
+ });
1970
+ });
1971
+
1972
+ test("a 400 about something else is Unknown, not PermissionDenied", async () => {
1973
+ apiGetSpy.mockResolvedValue(
1974
+ graphError(400, {
1975
+ error: { code: "BadRequest", message: "Invalid thread id." },
1976
+ }),
1977
+ );
1978
+
1979
+ await expect(lookup()).resolves.toEqual({
1980
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
1981
+ });
1982
+ });
1983
+
1984
+ test("404 is Unknown, and is logged as a warning: the chat id may not be Graph's", async () => {
1985
+ apiGetSpy.mockResolvedValue(
1986
+ graphError(404, {
1987
+ error: { code: "NotFound", message: "Resource not found." },
1988
+ }),
1989
+ );
1990
+
1991
+ await expect(lookup()).resolves.toEqual({
1992
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
1993
+ });
1994
+ expect(warnSpy).toHaveBeenCalledTimes(1);
1995
+ expect(String(warnSpy.mock.calls[0]![0])).toContain(GROUP_CHAT_ID);
1996
+ });
1997
+
1998
+ test.each([401, 429, 500, 503])(
1999
+ "HTTP %s is Unknown, without a warning (a passing throttle or outage)",
2000
+ async (statusCode: number) => {
2001
+ apiGetSpy.mockResolvedValue(
2002
+ graphError(statusCode, {
2003
+ error: { code: "Something", message: "Try again later." },
2004
+ }),
2005
+ );
2006
+
2007
+ await expect(lookup()).resolves.toEqual({
2008
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
2009
+ });
2010
+ expect(warnSpy).not.toHaveBeenCalled();
2011
+ },
2012
+ );
2013
+
2014
+ test("no Graph token (getValidAccessToken throws) is Unknown and never calls Graph", async () => {
2015
+ tokenSpy.mockRejectedValue(new Error("no token"));
2016
+
2017
+ await expect(lookup()).resolves.toEqual({
2018
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
2019
+ });
2020
+ expect(apiGetSpy).not.toHaveBeenCalled();
2021
+ });
2022
+
2023
+ test("a network failure (API.get throws) is Unknown — the lookup never throws", async () => {
2024
+ apiGetSpy.mockRejectedValue(new Error("ECONNRESET"));
2025
+
2026
+ await expect(lookup()).resolves.toEqual({
2027
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
2028
+ });
2029
+ });
2030
+
2031
+ test("an empty chat id is Unknown and makes no calls at all", async () => {
2032
+ await expect(lookup({ chatId: "" })).resolves.toEqual({
2033
+ status: MicrosoftTeamsChatTopicLookupStatus.Unknown,
2034
+ });
2035
+ expect(tokenSpy).not.toHaveBeenCalled();
2036
+ expect(apiGetSpy).not.toHaveBeenCalled();
2037
+ });
2038
+ });
2039
+
2040
+ describe("MicrosoftTeamsUtil.refreshChatNamesForProject (Refresh Chats)", () => {
2041
+ /*
2042
+ * The project's one Microsoft Teams connection, in memory. The rename and
2043
+ * the re-read after it run for real against it, so what a refresh returns
2044
+ * is what it actually stored.
2045
+ */
2046
+ let storedChats: Record<string, MicrosoftTeamsChat>;
2047
+ let connectionHasTenant: boolean;
2048
+ let projectId: ObjectID;
2049
+ let topicSpy: jest.SpyInstance;
2050
+ let renameSpy: jest.SpyInstance;
2051
+ let getProjectAuthSpy: jest.SpyInstance;
2052
+ let getChatsSpy: jest.SpyInstance;
2053
+ let updateSpy: jest.SpyInstance;
2054
+
2055
+ function connectionRow(): WorkspaceProjectAuthToken {
2056
+ return buildAuthRow({
2057
+ projectId: projectId,
2058
+ workspaceProjectId: connectionHasTenant ? TENANT_ID : undefined,
2059
+ availableChats: cloneChats(storedChats),
2060
+ });
2061
+ }
2062
+
2063
+ beforeEach(() => {
2064
+ /*
2065
+ * A fresh project per test: refreshes in flight are kept per project, so
2066
+ * a test that holds one open cannot leak into the next.
2067
+ */
2068
+ projectId = ObjectID.generate();
2069
+ storedChats = {};
2070
+ connectionHasTenant = true;
2071
+
2072
+ topicSpy = jest.spyOn(MicrosoftTeamsUtil, "getGroupChatTopicFromGraph");
2073
+ renameSpy = jest.spyOn(
2074
+ MicrosoftTeamsUtil,
2075
+ "renameChatsInProjectAuthTokens",
2076
+ );
2077
+ getChatsSpy = jest.spyOn(MicrosoftTeamsUtil, "getChatsForProject");
2078
+
2079
+ getProjectAuthSpy = jest.spyOn(
2080
+ WorkspaceProjectAuthTokenService,
2081
+ "getProjectAuth",
2082
+ );
2083
+ getProjectAuthSpy.mockImplementation(
2084
+ async (): Promise<WorkspaceProjectAuthToken> => {
2085
+ return connectionRow();
2086
+ },
2087
+ );
2088
+
2089
+ const findBySpy: jest.SpyInstance = jest.spyOn(
2090
+ WorkspaceProjectAuthTokenService,
2091
+ "findBy",
2092
+ );
2093
+ findBySpy.mockImplementation(
2094
+ async (): Promise<Array<WorkspaceProjectAuthToken>> => {
2095
+ return [connectionRow()];
2096
+ },
2097
+ );
2098
+
2099
+ updateSpy = jest.spyOn(WorkspaceProjectAuthTokenService, "updateOneById");
2100
+ updateSpy.mockImplementation(
2101
+ async (args: {
2102
+ data: { miscData: MicrosoftTeamsMiscData };
2103
+ }): Promise<void> => {
2104
+ storedChats = cloneChats(args.data.miscData.availableChats || {});
2105
+ },
2106
+ );
2107
+ });
2108
+
2109
+ function storeChats(chats: Array<MicrosoftTeamsChat>): void {
2110
+ storedChats = cloneChats(chatsById(chats));
2111
+ }
2112
+
2113
+ function topics(
2114
+ byChatId: Record<string, MicrosoftTeamsChatTopicLookup>,
2115
+ ): void {
2116
+ topicSpy.mockImplementation(
2117
+ async (data: {
2118
+ chatId: string;
2119
+ }): Promise<MicrosoftTeamsChatTopicLookup> => {
2120
+ return byChatId[data.chatId] || UNKNOWN;
2121
+ },
2122
+ );
2123
+ }
2124
+
2125
+ function chatNamesWritten(): Record<string, MicrosoftTeamsChatNameUpdate> {
2126
+ expect(renameSpy).toHaveBeenCalledTimes(1);
2127
+ return (
2128
+ renameSpy.mock.calls[0]![0] as {
2129
+ chatNames: Record<string, MicrosoftTeamsChatNameUpdate>;
2130
+ }
2131
+ ).chatNames;
2132
+ }
2133
+
2134
+ async function refresh(
2135
+ forProjectId?: ObjectID,
2136
+ ): Promise<MicrosoftTeamsChatNameRefreshResult> {
2137
+ return MicrosoftTeamsUtil.refreshChatNamesForProject({
2138
+ projectId: forProjectId || projectId,
2139
+ });
2140
+ }
2141
+
2142
+ test("reads the project's own Microsoft Teams connection", async () => {
2143
+ await refresh();
2144
+
2145
+ expect(getProjectAuthSpy).toHaveBeenCalledWith({
2146
+ projectId: projectId,
2147
+ workspaceType: WorkspaceType.MicrosoftTeams,
2148
+ });
2149
+ });
2150
+
2151
+ test("the issue: a chat stored under its members' names gets its Teams name and topic written", async () => {
2152
+ storeChats([buildChat()]);
2153
+ topics({ [GROUP_CHAT_ID]: FOUND });
2154
+
2155
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2156
+
2157
+ expect(renameSpy).toHaveBeenCalledWith({
2158
+ tenantId: TENANT_ID,
2159
+ chatNames: { [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } },
2160
+ });
2161
+ expect(result.chats[GROUP_CHAT_ID]).toStrictEqual(buildNamedChat());
2162
+ expect(storedChats[GROUP_CHAT_ID]).toStrictEqual(buildNamedChat());
2163
+ expect(result.permissionDeniedChatIds).toEqual([]);
2164
+ expect(result.failedChatIds).toEqual([]);
2165
+ });
2166
+
2167
+ test("the Graph token is fetched once for every chat, and handed to each lookup", async () => {
2168
+ const chats: Array<MicrosoftTeamsChat> = [];
2169
+ for (let index: number = 0; index < 7; index++) {
2170
+ chats.push(buildChat({ id: `19:chat-${index}@thread.v2` }));
2171
+ }
2172
+ storeChats(chats);
2173
+ topics({});
2174
+
2175
+ await refresh();
2176
+
2177
+ expect(tokenSpy).toHaveBeenCalledTimes(1);
2178
+ expect(tokenSpy).toHaveBeenCalledWith({
2179
+ authToken: "",
2180
+ projectId: projectId,
2181
+ });
2182
+ expect(topicSpy).toHaveBeenCalledTimes(7);
2183
+ for (const chat of chats) {
2184
+ expect(topicSpy).toHaveBeenCalledWith({
2185
+ projectId: projectId,
2186
+ chatId: chat.id,
2187
+ accessToken: ACCESS_TOKEN,
2188
+ });
2189
+ }
2190
+ });
2191
+
2192
+ test("no Graph token: the refresh fails loudly, and nothing is looked up or written", async () => {
2193
+ storeChats([buildChat()]);
2194
+ tokenSpy.mockRejectedValue(new Error("token endpoint down"));
2195
+
2196
+ await expect(refresh()).rejects.toThrow("token endpoint down");
2197
+ expect(topicSpy).not.toHaveBeenCalled();
2198
+ expect(renameSpy).not.toHaveBeenCalled();
2199
+ });
2200
+
2201
+ test("a chat renamed in Teams picks up the new name", async () => {
2202
+ storeChats([buildNamedChat()]);
2203
+ topics({
2204
+ [GROUP_CHAT_ID]: {
2205
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2206
+ topic: "Payments On-Call",
2207
+ },
2208
+ });
2209
+
2210
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2211
+
2212
+ expect(chatNamesWritten()).toStrictEqual({
2213
+ [GROUP_CHAT_ID]: { name: "Payments On-Call", topic: "Payments On-Call" },
2214
+ });
2215
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe("Payments On-Call");
2216
+ expect(result.chats[GROUP_CHAT_ID]!.topic).toBe("Payments On-Call");
2217
+ });
2218
+
2219
+ test("nothing is written when the name and topic are already right (the list is still re-read)", async () => {
2220
+ storeChats([buildNamedChat()]);
2221
+ topics({ [GROUP_CHAT_ID]: FOUND });
2222
+
2223
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2224
+
2225
+ expect(renameSpy).not.toHaveBeenCalled();
2226
+ expect(updateSpy).not.toHaveBeenCalled();
2227
+ expect(getChatsSpy).toHaveBeenCalledTimes(1);
2228
+ expect(result.chats[GROUP_CHAT_ID]).toStrictEqual(buildNamedChat());
2229
+ });
2230
+
2231
+ test("a chat removed mid-refresh is not returned even when nothing was written", async () => {
2232
+ storeChats([
2233
+ buildNamedChat(),
2234
+ buildChat({ id: "19:removed@thread.v2", name: "Soon gone" }),
2235
+ ]);
2236
+ topicSpy.mockImplementation(
2237
+ async (data: {
2238
+ chatId: string;
2239
+ }): Promise<MicrosoftTeamsChatTopicLookup> => {
2240
+ // The bot is removed from the other chat while this one is read.
2241
+ delete storedChats["19:removed@thread.v2"];
2242
+ return data.chatId === GROUP_CHAT_ID ? FOUND : DENIED;
2243
+ },
2244
+ );
2245
+
2246
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2247
+
2248
+ expect(renameSpy).not.toHaveBeenCalled();
2249
+ expect(Object.keys(result.chats)).toEqual([GROUP_CHAT_ID]);
2250
+ });
2251
+
2252
+ test("a chat already listed under its Teams name, but without the topic, gets the topic written", async () => {
2253
+ // Captured by an earlier build: the right name, but no topic to keep it by.
2254
+ const chat: MicrosoftTeamsChat = buildChat({ name: TEAMS_NAME });
2255
+ storeChats([chat]);
2256
+ topics({ [GROUP_CHAT_ID]: FOUND });
2257
+
2258
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2259
+
2260
+ expect(chatNamesWritten()).toStrictEqual({
2261
+ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME },
2262
+ });
2263
+ expect(result.chats[GROUP_CHAT_ID]!.topic).toBe(TEAMS_NAME);
2264
+ });
2265
+
2266
+ test("a new topic that cuts to the same display name is still written", async () => {
2267
+ // Both topics share their first 79 characters, so both display the same.
2268
+ const sharedStart: string = "x".repeat(79);
2269
+ const oldTopic: string = `${sharedStart} old ending`;
2270
+ const newTopic: string = `${sharedStart} new ending`;
2271
+ const displayName: string = `${sharedStart}…`;
2272
+ storeChats([buildChat({ name: displayName, topic: oldTopic })]);
2273
+ topics({
2274
+ [GROUP_CHAT_ID]: {
2275
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2276
+ topic: newTopic,
2277
+ },
2278
+ });
2279
+
2280
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2281
+
2282
+ expect(chatNamesWritten()).toStrictEqual({
2283
+ [GROUP_CHAT_ID]: { name: displayName, topic: newTopic },
2284
+ });
2285
+ expect(result.chats[GROUP_CHAT_ID]!.topic).toBe(newTopic);
2286
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(displayName);
2287
+ });
2288
+
2289
+ test("a chat whose name was removed in Teams goes back to its stored members and count, and loses its topic", async () => {
2290
+ storeChats([buildNamedChat()]);
2291
+ topics({ [GROUP_CHAT_ID]: NO_TOPIC });
2292
+
2293
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2294
+
2295
+ // No topic key at all: the write removes the stored one.
2296
+ expect(chatNamesWritten()).toStrictEqual({
2297
+ [GROUP_CHAT_ID]: { name: ISSUE_MEMBER_NAME },
2298
+ });
2299
+ expect(result.chats[GROUP_CHAT_ID]).toStrictEqual(buildChat());
2300
+ expect(Object.keys(storedChats[GROUP_CHAT_ID]!)).not.toContain("topic");
2301
+ });
2302
+
2303
+ test("the stored member count, not just the three names, sets '+ N more' after a name is removed", async () => {
2304
+ storeChats([
2305
+ buildNamedChat({
2306
+ memberNames: ["Alice", "Bob", "Carol"],
2307
+ memberCount: 6,
2308
+ }),
2309
+ ]);
2310
+ topics({ [GROUP_CHAT_ID]: NO_TOPIC });
2311
+
2312
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2313
+
2314
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(
2315
+ "Alice, Bob, Carol + 3 more",
2316
+ );
2317
+ });
2318
+
2319
+ test("a removed Teams name with no stored members becomes 'Group chat', not the old name", async () => {
2320
+ storeChats([buildLegacyChat({ name: TEAMS_NAME, topic: TEAMS_NAME })]);
2321
+ topics({ [GROUP_CHAT_ID]: NO_TOPIC });
2322
+
2323
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2324
+
2325
+ expect(chatNamesWritten()).toStrictEqual({
2326
+ [GROUP_CHAT_ID]: { name: "Group chat" },
2327
+ });
2328
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe("Group chat");
2329
+ expect(Object.keys(result.chats[GROUP_CHAT_ID]!)).not.toContain("topic");
2330
+ });
2331
+
2332
+ test("an unnamed chat with no stored members and no stored topic keeps its current name", async () => {
2333
+ storeChats([buildLegacyChat({ name: "Alice, Bob" })]);
2334
+ topics({ [GROUP_CHAT_ID]: NO_TOPIC });
2335
+
2336
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2337
+
2338
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe("Alice, Bob");
2339
+ expect(renameSpy).not.toHaveBeenCalled();
2340
+ });
2341
+
2342
+ test("an unnamed chat with an empty stored roster keeps its current name", async () => {
2343
+ storeChats([
2344
+ buildChat({ name: "Alice, Bob", memberNames: [], memberCount: 0 }),
2345
+ ]);
2346
+ topics({ [GROUP_CHAT_ID]: NO_TOPIC });
2347
+
2348
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2349
+
2350
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe("Alice, Bob");
2351
+ expect(renameSpy).not.toHaveBeenCalled();
2352
+ });
2353
+
2354
+ test("a legacy record without members still gets its Graph name", async () => {
2355
+ storeChats([buildLegacyChat()]);
2356
+ topics({ [GROUP_CHAT_ID]: FOUND });
2357
+
2358
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2359
+
2360
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(TEAMS_NAME);
2361
+ expect(result.chats[GROUP_CHAT_ID]!.topic).toBe(TEAMS_NAME);
2362
+ });
2363
+
2364
+ test("PermissionDenied is reported by chat id, and the chat keeps its name", async () => {
2365
+ storeChats([buildNamedChat()]);
2366
+ topics({ [GROUP_CHAT_ID]: DENIED });
2367
+
2368
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2369
+
2370
+ expect(result.permissionDeniedChatIds).toEqual([GROUP_CHAT_ID]);
2371
+ expect(result.failedChatIds).toEqual([]);
2372
+ expect(result.chats[GROUP_CHAT_ID]).toStrictEqual(buildNamedChat());
2373
+ expect(renameSpy).not.toHaveBeenCalled();
2374
+ });
2375
+
2376
+ test("Unknown is reported as failed by chat id, separately from PermissionDenied", async () => {
2377
+ storeChats([buildChat()]);
2378
+ topics({ [GROUP_CHAT_ID]: UNKNOWN });
2379
+
2380
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2381
+
2382
+ expect(result.permissionDeniedChatIds).toEqual([]);
2383
+ expect(result.failedChatIds).toEqual([GROUP_CHAT_ID]);
2384
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(ISSUE_MEMBER_NAME);
2385
+ expect(renameSpy).not.toHaveBeenCalled();
2386
+ });
2387
+
2388
+ test("mixed answers: writes what changed once, and reports the rest by id", async () => {
2389
+ storeChats([
2390
+ buildChat({ id: "19:found@thread.v2" }),
2391
+ buildNamedChat({ id: "19:same@thread.v2" }),
2392
+ buildChat({ id: "19:denied-1@thread.v2" }),
2393
+ buildChat({ id: "19:unknown@thread.v2" }),
2394
+ buildChat({ id: "19:denied-2@thread.v2" }),
2395
+ buildNamedChat({ id: "19:cleared@thread.v2" }),
2396
+ buildPersonalChat(),
2397
+ ]);
2398
+ topics({
2399
+ "19:found@thread.v2": {
2400
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2401
+ topic: "Payments on-call",
2402
+ },
2403
+ "19:same@thread.v2": FOUND,
2404
+ "19:denied-1@thread.v2": DENIED,
2405
+ "19:denied-2@thread.v2": DENIED,
2406
+ "19:unknown@thread.v2": UNKNOWN,
2407
+ "19:cleared@thread.v2": NO_TOPIC,
2408
+ });
2409
+
2410
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2411
+
2412
+ expect(result.permissionDeniedChatIds).toEqual([
2413
+ "19:denied-1@thread.v2",
2414
+ "19:denied-2@thread.v2",
2415
+ ]);
2416
+ expect(result.failedChatIds).toEqual(["19:unknown@thread.v2"]);
2417
+ expect(chatNamesWritten()).toStrictEqual({
2418
+ "19:found@thread.v2": {
2419
+ name: "Payments on-call",
2420
+ topic: "Payments on-call",
2421
+ },
2422
+ "19:cleared@thread.v2": { name: ISSUE_MEMBER_NAME },
2423
+ });
2424
+ expect(updateSpy).toHaveBeenCalledTimes(1);
2425
+ expect(result.chats["19:found@thread.v2"]!.name).toBe("Payments on-call");
2426
+ expect(result.chats["19:same@thread.v2"]!.name).toBe(TEAMS_NAME);
2427
+ expect(result.chats["19:denied-1@thread.v2"]!.name).toBe(ISSUE_MEMBER_NAME);
2428
+ expect(result.chats["19:cleared@thread.v2"]!.name).toBe(ISSUE_MEMBER_NAME);
2429
+ expect(result.chats[PERSONAL_CHAT_ID]).toStrictEqual(buildPersonalChat());
2430
+ });
2431
+
2432
+ test("personal chats are returned untouched and never looked up", async () => {
2433
+ storeChats([buildPersonalChat(), buildChat()]);
2434
+ topics({ [GROUP_CHAT_ID]: FOUND });
2435
+
2436
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2437
+
2438
+ expect(topicSpy).toHaveBeenCalledTimes(1);
2439
+ expect(topicSpy.mock.calls[0]![0]).toMatchObject({
2440
+ chatId: GROUP_CHAT_ID,
2441
+ });
2442
+ expect(result.chats[PERSONAL_CHAT_ID]).toStrictEqual(buildPersonalChat());
2443
+ });
2444
+
2445
+ test("only personal chats: no token, no Graph calls, no writes, nothing reported", async () => {
2446
+ storeChats([
2447
+ buildPersonalChat({ id: "a:1", name: "Jane" }),
2448
+ buildPersonalChat({ id: "a:2", name: "John" }),
2449
+ ]);
2450
+
2451
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2452
+
2453
+ expect(tokenSpy).not.toHaveBeenCalled();
2454
+ expect(topicSpy).not.toHaveBeenCalled();
2455
+ expect(renameSpy).not.toHaveBeenCalled();
2456
+ expect(Object.keys(result.chats).sort()).toEqual(["a:1", "a:2"]);
2457
+ expect(result.permissionDeniedChatIds).toEqual([]);
2458
+ expect(result.failedChatIds).toEqual([]);
2459
+ });
2460
+
2461
+ test("no Microsoft Teams connection: an empty result, no token and no calls", async () => {
2462
+ getProjectAuthSpy.mockResolvedValue(null);
2463
+
2464
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2465
+
2466
+ expect(result).toEqual({
2467
+ chats: {},
2468
+ permissionDeniedChatIds: [],
2469
+ failedChatIds: [],
2470
+ });
2471
+ expect(tokenSpy).not.toHaveBeenCalled();
2472
+ expect(topicSpy).not.toHaveBeenCalled();
2473
+ expect(renameSpy).not.toHaveBeenCalled();
2474
+ });
2475
+
2476
+ test("a connection without miscData: an empty result and no calls", async () => {
2477
+ getProjectAuthSpy.mockResolvedValue(
2478
+ buildAuthRow({
2479
+ projectId: projectId,
2480
+ workspaceProjectId: TENANT_ID,
2481
+ withoutMiscData: true,
2482
+ }),
2483
+ );
2484
+
2485
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2486
+
2487
+ expect(result.chats).toEqual({});
2488
+ expect(tokenSpy).not.toHaveBeenCalled();
2489
+ expect(topicSpy).not.toHaveBeenCalled();
2490
+ });
2491
+
2492
+ test("a connection without a tenant id returns the stored chats without a token or lookups", async () => {
2493
+ storeChats([buildChat()]);
2494
+ connectionHasTenant = false;
2495
+
2496
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2497
+
2498
+ expect(result.chats).toStrictEqual({ [GROUP_CHAT_ID]: buildChat() });
2499
+ expect(tokenSpy).not.toHaveBeenCalled();
2500
+ expect(topicSpy).not.toHaveBeenCalled();
2501
+ expect(renameSpy).not.toHaveBeenCalled();
2502
+ });
2503
+
2504
+ test("the chats returned after a write are re-read: a chat removed mid-refresh is not returned", async () => {
2505
+ storeChats([
2506
+ buildChat(),
2507
+ buildChat({ id: "19:removed@thread.v2", name: "Soon gone" }),
2508
+ ]);
2509
+ topicSpy.mockImplementation(
2510
+ async (data: {
2511
+ chatId: string;
2512
+ }): Promise<MicrosoftTeamsChatTopicLookup> => {
2513
+ // The bot is removed from the other chat while this one is read.
2514
+ delete storedChats["19:removed@thread.v2"];
2515
+ return data.chatId === GROUP_CHAT_ID ? FOUND : UNKNOWN;
2516
+ },
2517
+ );
2518
+
2519
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2520
+
2521
+ expect(getChatsSpy).toHaveBeenCalledTimes(1);
2522
+ expect(getChatsSpy).toHaveBeenCalledWith({ projectId: projectId });
2523
+ expect(Object.keys(result.chats)).toEqual([GROUP_CHAT_ID]);
2524
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(TEAMS_NAME);
2525
+ expect(storedChats["19:removed@thread.v2"]).toBeUndefined();
2526
+ // The id is still reported; dropping ids of chats that are gone is the route's job.
2527
+ expect(result.failedChatIds).toEqual(["19:removed@thread.v2"]);
2528
+ });
2529
+
2530
+ test("a chat removed mid-refresh is not brought back by the write, even when its name was read", async () => {
2531
+ storeChats([
2532
+ buildChat(),
2533
+ buildChat({ id: "19:removed@thread.v2", name: "Soon gone" }),
2534
+ ]);
2535
+ topicSpy.mockImplementation(
2536
+ async (data: {
2537
+ chatId: string;
2538
+ }): Promise<MicrosoftTeamsChatTopicLookup> => {
2539
+ delete storedChats["19:removed@thread.v2"];
2540
+ return {
2541
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2542
+ topic: `Name of ${data.chatId}`,
2543
+ };
2544
+ },
2545
+ );
2546
+
2547
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2548
+
2549
+ expect(Object.keys(chatNamesWritten()).sort()).toEqual(
2550
+ [GROUP_CHAT_ID, "19:removed@thread.v2"].sort(),
2551
+ );
2552
+ expect(storedChats["19:removed@thread.v2"]).toBeUndefined();
2553
+ expect(result.chats["19:removed@thread.v2"]).toBeUndefined();
2554
+ });
2555
+
2556
+ test("the connection row it read is not mutated in place", async () => {
2557
+ storeChats([buildChat()]);
2558
+ const readRow: WorkspaceProjectAuthToken = connectionRow();
2559
+ const snapshot: string = JSON.stringify(readRow.miscData);
2560
+ getProjectAuthSpy.mockResolvedValueOnce(readRow);
2561
+ topics({ [GROUP_CHAT_ID]: FOUND });
2562
+
2563
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2564
+
2565
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(TEAMS_NAME);
2566
+ expect(JSON.stringify(readRow.miscData)).toBe(snapshot);
2567
+ });
2568
+
2569
+ test("long Teams names are cut for display on refresh too, and the topic is kept whole", async () => {
2570
+ storeChats([buildChat()]);
2571
+ topics({
2572
+ [GROUP_CHAT_ID]: {
2573
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2574
+ topic: "x".repeat(200),
2575
+ },
2576
+ });
2577
+
2578
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2579
+
2580
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(`${"x".repeat(79)}…`);
2581
+ expect(result.chats[GROUP_CHAT_ID]!.topic).toBe("x".repeat(200));
2582
+ });
2583
+
2584
+ test("lookups run at most 5 at a time, and every chat is looked up", async () => {
2585
+ const chats: Array<MicrosoftTeamsChat> = [];
2586
+ for (let index: number = 0; index < 12; index++) {
2587
+ chats.push(buildChat({ id: `19:chat-${index}@thread.v2` }));
2588
+ }
2589
+ storeChats(chats);
2590
+
2591
+ // Each lookup stays pending until the test answers it.
2592
+ const pending: Array<{
2593
+ chatId: string;
2594
+ answer: Deferred<MicrosoftTeamsChatTopicLookup>;
2595
+ }> = [];
2596
+ let inFlight: number = 0;
2597
+ let maxInFlight: number = 0;
2598
+
2599
+ topicSpy.mockImplementation(
2600
+ (data: { chatId: string }): Promise<MicrosoftTeamsChatTopicLookup> => {
2601
+ const answer: Deferred<MicrosoftTeamsChatTopicLookup> =
2602
+ createDeferred<MicrosoftTeamsChatTopicLookup>();
2603
+ pending.push({ chatId: data.chatId, answer: answer });
2604
+ inFlight++;
2605
+ maxInFlight = Math.max(maxInFlight, inFlight);
2606
+ return answer.promise.then(
2607
+ (
2608
+ lookupResult: MicrosoftTeamsChatTopicLookup,
2609
+ ): MicrosoftTeamsChatTopicLookup => {
2610
+ inFlight--;
2611
+ return lookupResult;
2612
+ },
2613
+ );
2614
+ },
2615
+ );
2616
+
2617
+ const answerAll: (from: number, to: number) => void = (
2618
+ from: number,
2619
+ to: number,
2620
+ ): void => {
2621
+ for (const lookup of pending.slice(from, to)) {
2622
+ lookup.answer.resolve({
2623
+ status: MicrosoftTeamsChatTopicLookupStatus.Found,
2624
+ topic: `Name of ${lookup.chatId}`,
2625
+ });
2626
+ }
2627
+ };
2628
+
2629
+ const refreshing: Promise<MicrosoftTeamsChatNameRefreshResult> = refresh();
2630
+
2631
+ await waitUntil(() => {
2632
+ return pending.length === 5;
2633
+ });
2634
+ // Every chance to start a sixth before the first five are answered.
2635
+ await flushMicrotasks();
2636
+ await flushMicrotasks();
2637
+ expect(pending).toHaveLength(5);
2638
+ expect(inFlight).toBe(5);
2639
+
2640
+ answerAll(0, 5);
2641
+ await waitUntil(() => {
2642
+ return pending.length === 10;
2643
+ });
2644
+ await flushMicrotasks();
2645
+ expect(pending).toHaveLength(10);
2646
+ expect(inFlight).toBe(5);
2647
+
2648
+ answerAll(5, 10);
2649
+ await waitUntil(() => {
2650
+ return pending.length === 12;
2651
+ });
2652
+ await flushMicrotasks();
2653
+ expect(pending).toHaveLength(12);
2654
+ expect(inFlight).toBe(2);
2655
+
2656
+ answerAll(10, 12);
2657
+ const result: MicrosoftTeamsChatNameRefreshResult = await refreshing;
2658
+
2659
+ expect(maxInFlight).toBe(5);
2660
+ expect(
2661
+ pending.map(
2662
+ (lookup: {
2663
+ chatId: string;
2664
+ answer: Deferred<MicrosoftTeamsChatTopicLookup>;
2665
+ }) => {
2666
+ return lookup.chatId;
2667
+ },
2668
+ ),
2669
+ ).toEqual(
2670
+ chats.map((chat: MicrosoftTeamsChat) => {
2671
+ return chat.id;
2672
+ }),
2673
+ );
2674
+ expect(Object.keys(chatNamesWritten())).toHaveLength(12);
2675
+ expect(result.chats["19:chat-11@thread.v2"]!.name).toBe(
2676
+ "Name of 19:chat-11@thread.v2",
2677
+ );
2678
+ });
2679
+
2680
+ test("two refreshes of one project at once share a single run and a single result", async () => {
2681
+ storeChats([buildChat()]);
2682
+ topics({ [GROUP_CHAT_ID]: FOUND });
2683
+ const heldConnection: Deferred<WorkspaceProjectAuthToken> =
2684
+ createDeferred<WorkspaceProjectAuthToken>();
2685
+ getProjectAuthSpy.mockImplementationOnce(
2686
+ (): Promise<WorkspaceProjectAuthToken> => {
2687
+ return heldConnection.promise;
2688
+ },
2689
+ );
2690
+
2691
+ const first: Promise<MicrosoftTeamsChatNameRefreshResult> = refresh();
2692
+ const second: Promise<MicrosoftTeamsChatNameRefreshResult> = refresh();
2693
+ await waitUntil(() => {
2694
+ return getProjectAuthSpy.mock.calls.length > 0;
2695
+ });
2696
+ await flushMicrotasks();
2697
+ expect(getProjectAuthSpy).toHaveBeenCalledTimes(1);
2698
+
2699
+ heldConnection.resolve(connectionRow());
2700
+ const results: Array<MicrosoftTeamsChatNameRefreshResult> =
2701
+ await Promise.all([first, second]);
2702
+
2703
+ expect(results[0]).toBe(results[1]);
2704
+ expect(results[0]!.chats[GROUP_CHAT_ID]!.name).toBe(TEAMS_NAME);
2705
+ expect(tokenSpy).toHaveBeenCalledTimes(1);
2706
+ expect(topicSpy).toHaveBeenCalledTimes(1);
2707
+ expect(renameSpy).toHaveBeenCalledTimes(1);
2708
+ // getChatsForProject reads the connection once more, after the write.
2709
+ expect(getProjectAuthSpy).toHaveBeenCalledTimes(2);
2710
+ });
2711
+
2712
+ test("refreshes of different projects run separately", async () => {
2713
+ const otherProjectId: ObjectID = ObjectID.generate();
2714
+ storeChats([buildChat()]);
2715
+ topics({ [GROUP_CHAT_ID]: DENIED });
2716
+ const heldConnection: Deferred<WorkspaceProjectAuthToken> =
2717
+ createDeferred<WorkspaceProjectAuthToken>();
2718
+ getProjectAuthSpy.mockImplementation(
2719
+ (): Promise<WorkspaceProjectAuthToken> => {
2720
+ return heldConnection.promise;
2721
+ },
2722
+ );
2723
+
2724
+ const first: Promise<MicrosoftTeamsChatNameRefreshResult> = refresh();
2725
+ const second: Promise<MicrosoftTeamsChatNameRefreshResult> =
2726
+ refresh(otherProjectId);
2727
+ await waitUntil(() => {
2728
+ return getProjectAuthSpy.mock.calls.length === 2;
2729
+ });
2730
+
2731
+ expect(getProjectAuthSpy).toHaveBeenCalledWith({
2732
+ projectId: projectId,
2733
+ workspaceType: WorkspaceType.MicrosoftTeams,
2734
+ });
2735
+ expect(getProjectAuthSpy).toHaveBeenCalledWith({
2736
+ projectId: otherProjectId,
2737
+ workspaceType: WorkspaceType.MicrosoftTeams,
2738
+ });
2739
+
2740
+ heldConnection.resolve(connectionRow());
2741
+ const results: Array<MicrosoftTeamsChatNameRefreshResult> =
2742
+ await Promise.all([first, second]);
2743
+
2744
+ expect(results[0]).not.toBe(results[1]);
2745
+ expect(topicSpy).toHaveBeenCalledTimes(2);
2746
+ });
2747
+
2748
+ test("once a refresh has finished, the next one is a new run", async () => {
2749
+ storeChats([buildChat()]);
2750
+ topics({ [GROUP_CHAT_ID]: UNKNOWN });
2751
+
2752
+ const first: MicrosoftTeamsChatNameRefreshResult = await refresh();
2753
+ const second: MicrosoftTeamsChatNameRefreshResult = await refresh();
2754
+
2755
+ expect(first).not.toBe(second);
2756
+ // Each run reads the connection, then re-reads the stored chats.
2757
+ expect(getProjectAuthSpy).toHaveBeenCalledTimes(4);
2758
+ expect(topicSpy).toHaveBeenCalledTimes(2);
2759
+ });
2760
+
2761
+ test("a refresh that failed is not reused: both callers see the failure, and the next call runs again", async () => {
2762
+ storeChats([buildChat()]);
2763
+ topics({ [GROUP_CHAT_ID]: FOUND });
2764
+ tokenSpy.mockRejectedValueOnce(new Error("token endpoint down"));
2765
+ const heldConnection: Deferred<WorkspaceProjectAuthToken> =
2766
+ createDeferred<WorkspaceProjectAuthToken>();
2767
+ getProjectAuthSpy.mockImplementationOnce(
2768
+ (): Promise<WorkspaceProjectAuthToken> => {
2769
+ return heldConnection.promise;
2770
+ },
2771
+ );
2772
+
2773
+ // Both callers are listening before the shared run is let go.
2774
+ const rejections: Promise<Array<unknown>> = Promise.all([
2775
+ rejectionOf(refresh()),
2776
+ rejectionOf(refresh()),
2777
+ ]);
2778
+ heldConnection.resolve(connectionRow());
2779
+ const errors: Array<unknown> = await rejections;
2780
+
2781
+ for (const error of errors) {
2782
+ expect(error).toBeInstanceOf(Error);
2783
+ expect((error as Error).message).toBe("token endpoint down");
2784
+ }
2785
+ expect(errors[0]).toBe(errors[1]);
2786
+ expect(getProjectAuthSpy).toHaveBeenCalledTimes(1);
2787
+
2788
+ const retried: MicrosoftTeamsChatNameRefreshResult = await refresh();
2789
+
2790
+ expect(retried.chats[GROUP_CHAT_ID]!.name).toBe(TEAMS_NAME);
2791
+ expect(tokenSpy).toHaveBeenCalledTimes(2);
2792
+ });
2793
+
2794
+ test("a failing rename write surfaces as an error (the caller shows it)", async () => {
2795
+ storeChats([buildChat()]);
2796
+ topics({ [GROUP_CHAT_ID]: FOUND });
2797
+ renameSpy.mockRejectedValue(new Error("write failed"));
2798
+
2799
+ await expect(refresh()).rejects.toThrow("write failed");
2800
+ });
2801
+
2802
+ test("end to end through the real Graph lookup: one token, every chat, and the names written", async () => {
2803
+ topicSpy.mockRestore();
2804
+ storeChats([
2805
+ buildChat({ id: "19:one@thread.v2" }),
2806
+ buildChat({ id: "19:two@thread.v2" }),
2807
+ buildChat({ id: "19:three@thread.v2" }),
2808
+ ]);
2809
+ // Graph names each chat after its own id, read back from the request.
2810
+ apiGetSpy.mockImplementation(
2811
+ async (options: {
2812
+ url: { toString: () => string };
2813
+ }): Promise<HTTPResponse<JSONObject>> => {
2814
+ const encodedChatId: string =
2815
+ options.url.toString().split("/chats/")[1] || "";
2816
+ return graphOk({ topic: `Named ${decodeURIComponent(encodedChatId)}` });
2817
+ },
2818
+ );
2819
+
2820
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2821
+
2822
+ expect(tokenSpy).toHaveBeenCalledTimes(1);
2823
+ expect(apiGetSpy).toHaveBeenCalledTimes(3);
2824
+ for (const call of apiGetSpy.mock.calls) {
2825
+ expect(
2826
+ (call[0] as { headers: JSONObject }).headers["Authorization"],
2827
+ ).toBe(`Bearer ${ACCESS_TOKEN}`);
2828
+ }
2829
+ for (const chatId of [
2830
+ "19:one@thread.v2",
2831
+ "19:two@thread.v2",
2832
+ "19:three@thread.v2",
2833
+ ]) {
2834
+ expect(result.chats[chatId]!.name).toBe(`Named ${chatId}`);
2835
+ expect(result.chats[chatId]!.topic).toBe(`Named ${chatId}`);
2836
+ }
2837
+ });
2838
+
2839
+ test("end to end through the real Graph lookup: a 403 is reported as a permission problem", async () => {
2840
+ topicSpy.mockRestore();
2841
+ storeChats([buildChat()]);
2842
+ apiGetSpy.mockResolvedValue(
2843
+ graphError(403, {
2844
+ error: { code: "Forbidden", message: "Missing role permissions." },
2845
+ }),
2846
+ );
2847
+
2848
+ const result: MicrosoftTeamsChatNameRefreshResult = await refresh();
2849
+
2850
+ expect(result.permissionDeniedChatIds).toEqual([GROUP_CHAT_ID]);
2851
+ expect(result.chats[GROUP_CHAT_ID]!.name).toBe(ISSUE_MEMBER_NAME);
2852
+ });
2853
+ });
2854
+
2855
+ describe("MicrosoftTeamsUtil.renameChatsInProjectAuthTokens", () => {
2856
+ let updateSpy: jest.SpyInstance;
2857
+
2858
+ beforeEach(() => {
2859
+ updateSpy = jest
2860
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
2861
+ .mockResolvedValue(undefined as never);
2862
+ });
2863
+
2864
+ function mockRows(rows: Array<WorkspaceProjectAuthToken>): jest.SpyInstance {
2865
+ return jest
2866
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
2867
+ .mockResolvedValue(rows);
2868
+ }
2869
+
2870
+ async function rename(
2871
+ chatNames: Record<string, MicrosoftTeamsChatNameUpdate>,
2872
+ ): Promise<void> {
2873
+ await MicrosoftTeamsUtil.renameChatsInProjectAuthTokens({
2874
+ tenantId: TENANT_ID,
2875
+ chatNames: chatNames,
2876
+ });
2877
+ }
2878
+
2879
+ function writtenChats(callIndex: number): Record<string, MicrosoftTeamsChat> {
2880
+ return updateSpy.mock.calls[callIndex]![0].data.miscData.availableChats;
2881
+ }
2882
+
2883
+ test("queries every Microsoft Teams connection of the tenant as root", async () => {
2884
+ const findBySpy: jest.SpyInstance = mockRows([]);
2885
+
2886
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
2887
+
2888
+ expect(findBySpy).toHaveBeenCalledWith({
2889
+ query: {
2890
+ workspaceType: WorkspaceType.MicrosoftTeams,
2891
+ workspaceProjectId: TENANT_ID,
2892
+ },
2893
+ select: { _id: true, miscData: true },
2894
+ limit: LIMIT_MAX,
2895
+ skip: 0,
2896
+ props: { isRoot: true },
2897
+ });
2898
+ });
2899
+
2900
+ test("writes the name and topic on every project of the tenant", async () => {
2901
+ const rowOne: WorkspaceProjectAuthToken = buildAuthRow({
2902
+ availableChats: { [GROUP_CHAT_ID]: buildChat() },
2903
+ });
2904
+ const rowTwo: WorkspaceProjectAuthToken = buildAuthRow({
2905
+ availableChats: { [GROUP_CHAT_ID]: buildChat() },
2906
+ });
2907
+ mockRows([rowOne, rowTwo]);
2908
+
2909
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
2910
+
2911
+ expect(updateSpy).toHaveBeenCalledTimes(2);
2912
+ const updatedIds: Array<string> = updateSpy.mock.calls.map(
2913
+ (call: Array<any>) => {
2914
+ return call[0].id.toString();
2915
+ },
2916
+ );
2917
+ expect(updatedIds.sort()).toEqual(
2918
+ [rowOne.id!.toString(), rowTwo.id!.toString()].sort(),
2919
+ );
2920
+ for (let index: number = 0; index < 2; index++) {
2921
+ expect(writtenChats(index)[GROUP_CHAT_ID]).toStrictEqual(
2922
+ buildNamedChat(),
2923
+ );
2924
+ expect(updateSpy.mock.calls[index]![0].props).toEqual({ isRoot: true });
2925
+ }
2926
+ });
2927
+
2928
+ test("the name and topic are merged onto the row's current record, not a stale copy", async () => {
2929
+ // The chat was re-captured (fresh serviceUrl and roster) after the refresh read it.
2930
+ const current: MicrosoftTeamsChat = buildChat({
2931
+ serviceUrl: OLD_SERVICE_URL,
2932
+ addedAt: "2026-09-28T00:00:00.000Z",
2933
+ memberNames: ["Alice", "Bob", "Carol"],
2934
+ memberCount: 9,
2935
+ memberAadObjectIds: ["aad-a"],
2936
+ });
2937
+ mockRows([buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: current } })]);
2938
+
2939
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
2940
+
2941
+ expect(writtenChats(0)[GROUP_CHAT_ID]).toStrictEqual({
2942
+ ...current,
2943
+ name: TEAMS_NAME,
2944
+ topic: TEAMS_NAME,
2945
+ });
2946
+ });
2947
+
2948
+ test("an update without a topic removes the stored one", async () => {
2949
+ const current: MicrosoftTeamsChat = buildNamedChat();
2950
+ mockRows([buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: current } })]);
2951
+
2952
+ await rename({ [GROUP_CHAT_ID]: { name: ISSUE_MEMBER_NAME } });
2953
+
2954
+ const expected: MicrosoftTeamsChat = {
2955
+ ...current,
2956
+ name: ISSUE_MEMBER_NAME,
2957
+ };
2958
+ delete expected.topic;
2959
+ expect(writtenChats(0)[GROUP_CHAT_ID]).toStrictEqual(expected);
2960
+ expect(Object.keys(writtenChats(0)[GROUP_CHAT_ID]!)).not.toContain("topic");
2961
+ });
2962
+
2963
+ test("the same name without a topic still removes a stored topic", async () => {
2964
+ // Graph now says the chat has no name, and the member name happens to match.
2965
+ mockRows([
2966
+ buildAuthRow({
2967
+ availableChats: {
2968
+ [GROUP_CHAT_ID]: buildChat({ topic: "An old Teams name" }),
2969
+ },
2970
+ }),
2971
+ ]);
2972
+
2973
+ await rename({ [GROUP_CHAT_ID]: { name: ISSUE_MEMBER_NAME } });
2974
+
2975
+ expect(updateSpy).toHaveBeenCalledTimes(1);
2976
+ expect(writtenChats(0)[GROUP_CHAT_ID]).toStrictEqual(buildChat());
2977
+ });
2978
+
2979
+ test("the same name with a new topic is written", async () => {
2980
+ mockRows([
2981
+ buildAuthRow({
2982
+ availableChats: { [GROUP_CHAT_ID]: buildChat({ name: TEAMS_NAME }) },
2983
+ }),
2984
+ ]);
2985
+
2986
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
2987
+
2988
+ expect(updateSpy).toHaveBeenCalledTimes(1);
2989
+ expect(writtenChats(0)[GROUP_CHAT_ID]!.topic).toBe(TEAMS_NAME);
2990
+ });
2991
+
2992
+ test("a chat removed while names were being read is not brought back", async () => {
2993
+ mockRows([
2994
+ buildAuthRow({
2995
+ availableChats: {
2996
+ "19:other@thread.v2": buildChat({ id: "19:other@thread.v2" }),
2997
+ },
2998
+ }),
2999
+ ]);
3000
+
3001
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
3002
+
3003
+ expect(updateSpy).not.toHaveBeenCalled();
3004
+ });
3005
+
3006
+ test("a row that already has the same name and topic is not written", async () => {
3007
+ mockRows([
3008
+ buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: buildNamedChat() } }),
3009
+ ]);
3010
+
3011
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
3012
+
3013
+ expect(updateSpy).not.toHaveBeenCalled();
3014
+ });
3015
+
3016
+ test("a row that already has the name and no topic is not written for a topic-less update", async () => {
3017
+ mockRows([
3018
+ buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: buildChat() } }),
3019
+ ]);
3020
+
3021
+ await rename({ [GROUP_CHAT_ID]: { name: ISSUE_MEMBER_NAME } });
3022
+
3023
+ expect(updateSpy).not.toHaveBeenCalled();
3024
+ });
3025
+
3026
+ test("only the rows that need it are written", async () => {
3027
+ const staleRow: WorkspaceProjectAuthToken = buildAuthRow({
3028
+ availableChats: { [GROUP_CHAT_ID]: buildChat() },
3029
+ });
3030
+ mockRows([
3031
+ buildAuthRow({ availableChats: { [GROUP_CHAT_ID]: buildNamedChat() } }),
3032
+ staleRow,
3033
+ buildAuthRow({ availableChats: {} }),
3034
+ ]);
3035
+
3036
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
3037
+
3038
+ expect(updateSpy).toHaveBeenCalledTimes(1);
3039
+ expect(updateSpy.mock.calls[0]![0].id.toString()).toBe(
3040
+ staleRow.id!.toString(),
3041
+ );
3042
+ });
3043
+
3044
+ test("several chats are renamed in a single write per row", async () => {
3045
+ mockRows([
3046
+ buildAuthRow({
3047
+ availableChats: {
3048
+ "19:one@thread.v2": buildChat({ id: "19:one@thread.v2" }),
3049
+ "19:two@thread.v2": buildNamedChat({ id: "19:two@thread.v2" }),
3050
+ },
3051
+ }),
3052
+ ]);
3053
+
3054
+ await rename({
3055
+ "19:one@thread.v2": { name: "One", topic: "One" },
3056
+ "19:two@thread.v2": { name: ISSUE_MEMBER_NAME },
3057
+ });
3058
+
3059
+ expect(updateSpy).toHaveBeenCalledTimes(1);
3060
+ const availableChats: Record<string, MicrosoftTeamsChat> = writtenChats(0);
3061
+ expect(availableChats["19:one@thread.v2"]!.name).toBe("One");
3062
+ expect(availableChats["19:one@thread.v2"]!.topic).toBe("One");
3063
+ expect(availableChats["19:two@thread.v2"]!.name).toBe(ISSUE_MEMBER_NAME);
3064
+ expect(Object.keys(availableChats["19:two@thread.v2"]!)).not.toContain(
3065
+ "topic",
3066
+ );
3067
+ });
3068
+
3069
+ test("other chats and the rest of miscData are kept", async () => {
3070
+ const otherChat: MicrosoftTeamsChat = buildChat({
3071
+ id: "19:other@thread.v2",
3072
+ name: "Other chat",
3073
+ });
3074
+ mockRows([
3075
+ buildAuthRow({
3076
+ miscData: {
3077
+ botId: "bot-1",
3078
+ tenantId: TENANT_ID,
3079
+ availableChats: {
3080
+ [GROUP_CHAT_ID]: buildChat(),
3081
+ [otherChat.id]: otherChat,
3082
+ },
3083
+ } as unknown as MicrosoftTeamsMiscData,
3084
+ }),
3085
+ ]);
3086
+
3087
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
3088
+
3089
+ const miscData: JSONObject = updateSpy.mock.calls[0]![0].data.miscData;
3090
+ expect(miscData["botId"]).toBe("bot-1");
3091
+ expect(miscData["tenantId"]).toBe(TENANT_ID);
3092
+ expect(writtenChats(0)[GROUP_CHAT_ID]).toStrictEqual(buildNamedChat());
3093
+ expect(writtenChats(0)[otherChat.id]).toStrictEqual(otherChat);
3094
+ });
3095
+
3096
+ test("rows without miscData or without chats are skipped", async () => {
3097
+ mockRows([
3098
+ buildAuthRow({ withoutMiscData: true }),
3099
+ buildAuthRow({ miscData: {} as MicrosoftTeamsMiscData }),
3100
+ ]);
3101
+
3102
+ await rename({ [GROUP_CHAT_ID]: { name: TEAMS_NAME, topic: TEAMS_NAME } });
3103
+
3104
+ expect(updateSpy).not.toHaveBeenCalled();
3105
+ });
3106
+
3107
+ test("the row's original miscData object is not mutated", async () => {
3108
+ const row: WorkspaceProjectAuthToken = buildAuthRow({
3109
+ availableChats: { [GROUP_CHAT_ID]: buildNamedChat() },
3110
+ });
3111
+ const snapshot: string = JSON.stringify(row.miscData);
3112
+ mockRows([row]);
3113
+
3114
+ await rename({ [GROUP_CHAT_ID]: { name: ISSUE_MEMBER_NAME } });
3115
+
3116
+ expect(updateSpy).toHaveBeenCalledTimes(1);
3117
+ expect(JSON.stringify(row.miscData)).toBe(snapshot);
3118
+ });
3119
+ });