@oneuptime/common 11.7.0 → 11.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +1 -0
  2. package/Models/DatabaseModels/Incident.ts +2 -0
  3. package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
  4. package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
  5. package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
  6. package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
  7. package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
  8. package/Models/DatabaseModels/Monitor.ts +1 -0
  9. package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
  10. package/Models/DatabaseModels/NetworkSite.ts +1 -0
  11. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
  14. package/Models/DatabaseModels/Probe.ts +13 -13
  15. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
  16. package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
  17. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
  18. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
  19. package/Models/DatabaseModels/StatusPage.ts +1 -0
  20. package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
  21. package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
  22. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
  23. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
  24. package/Server/API/BaseAPI.ts +17 -1
  25. package/Server/API/CommonAPI.ts +32 -0
  26. package/Server/API/MicrosoftTeamsAPI.ts +99 -2
  27. package/Server/API/ProbeAPI.ts +2 -0
  28. package/Server/API/SlackAPI.ts +62 -0
  29. package/Server/API/StatusPageAPI.ts +157 -192
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  34. package/Server/Services/DatabaseService.ts +11 -3
  35. package/Server/Services/MonitorGroupService.ts +145 -0
  36. package/Server/Services/MonitorService.ts +231 -30
  37. package/Server/Services/ProbeService.ts +319 -16
  38. package/Server/Services/StatusPageService.ts +72 -42
  39. package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
  40. package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
  41. package/Server/Utils/Monitor/MonitorResource.ts +88 -35
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
  44. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
  45. package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
  46. package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
  47. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
  48. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
  49. package/Tests/Server/API/ProbeAPI.test.ts +6 -0
  50. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
  51. package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
  52. package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
  53. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
  54. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
  55. package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
  56. package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
  57. package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
  58. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
  59. package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
  60. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
  61. package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
  62. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
  63. package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
  64. package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
  65. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
  66. package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
  67. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
  68. package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
  69. package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
  70. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
  71. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
  72. package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
  73. package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
  74. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
  75. package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
  76. package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
  77. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
  78. package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
  79. package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
  80. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
  81. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
  82. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
  84. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
  85. package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
  86. package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
  87. package/Tests/Types/DateUserTimezone.test.ts +205 -0
  88. package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
  89. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
  90. package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
  91. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
  92. package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
  93. package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
  94. package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
  95. package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
  96. package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
  97. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
  98. package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
  99. package/Tests/UI/Utils/Project.test.ts +809 -0
  100. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
  101. package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
  102. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
  103. package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
  104. package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
  105. package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
  106. package/Types/Date.ts +119 -50
  107. package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
  108. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
  109. package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
  110. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
  111. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  112. package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
  113. package/UI/Components/Forms/Fields/FormField.tsx +13 -3
  114. package/UI/Components/Forms/ModelForm.tsx +41 -18
  115. package/UI/Components/Input/Input.tsx +6 -1
  116. package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
  117. package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
  118. package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
  119. package/UI/Components/ModelTable/Column.ts +12 -0
  120. package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
  121. package/UI/Components/Navbar/NavBar.tsx +66 -1
  122. package/UI/Components/Table/Table.tsx +52 -13
  123. package/UI/Components/Table/Types/Column.ts +18 -0
  124. package/UI/Components/TimePicker/TimePicker.tsx +8 -4
  125. package/UI/Utils/Project.ts +214 -18
  126. package/UI/Utils/TableColumnsToCsv.ts +86 -6
  127. package/UI/Utils/User.ts +19 -0
  128. package/UI/esbuild-config.js +34 -0
  129. package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
  130. package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
  131. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  133. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  134. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
  135. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
  137. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
  139. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
  140. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
  141. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  142. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
  143. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  144. package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
  145. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  146. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
  147. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  148. package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
  149. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  150. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
  151. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
  152. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  153. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  154. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
  155. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  156. package/build/dist/Models/DatabaseModels/Probe.js +13 -13
  157. package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
  158. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
  159. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  160. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
  161. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
  162. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
  163. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  164. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
  165. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  166. package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
  167. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  168. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
  169. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  170. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
  171. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  172. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
  173. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
  174. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  175. package/build/dist/Server/API/BaseAPI.js +12 -1
  176. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  177. package/build/dist/Server/API/CommonAPI.js +21 -0
  178. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  179. package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
  180. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  181. package/build/dist/Server/API/ProbeAPI.js +2 -0
  182. package/build/dist/Server/API/ProbeAPI.js.map +1 -1
  183. package/build/dist/Server/API/SlackAPI.js +44 -0
  184. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  185. package/build/dist/Server/API/StatusPageAPI.js +98 -152
  186. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  187. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
  188. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
  189. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
  190. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
  191. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
  192. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
  193. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  194. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  195. package/build/dist/Server/Services/DatabaseService.js +10 -2
  196. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  197. package/build/dist/Server/Services/MonitorGroupService.js +118 -0
  198. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  199. package/build/dist/Server/Services/MonitorService.js +198 -24
  200. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  201. package/build/dist/Server/Services/ProbeService.js +238 -9
  202. package/build/dist/Server/Services/ProbeService.js.map +1 -1
  203. package/build/dist/Server/Services/StatusPageService.js +46 -39
  204. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  205. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
  206. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
  208. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
  210. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  211. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
  212. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  213. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
  214. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
  215. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
  216. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  217. package/build/dist/Types/Date.js +106 -35
  218. package/build/dist/Types/Date.js.map +1 -1
  219. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
  220. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  221. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
  222. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  223. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
  224. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  225. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
  226. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
  227. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
  228. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  229. package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
  230. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  231. package/build/dist/UI/Components/Input/Input.js +6 -1
  232. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  233. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
  234. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  235. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
  236. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  237. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
  238. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  239. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
  240. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
  241. package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
  242. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  243. package/build/dist/UI/Components/Table/Table.js +31 -11
  244. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  245. package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
  246. package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
  247. package/build/dist/UI/Utils/Project.js +161 -15
  248. package/build/dist/UI/Utils/Project.js.map +1 -1
  249. package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
  250. package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
  251. package/build/dist/UI/Utils/User.js +19 -0
  252. package/build/dist/UI/Utils/User.js.map +1 -1
  253. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
  254. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
  255. package/package.json +4 -3
  256. package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
  257. package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
  258. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
  259. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
  260. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
  261. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
  262. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
  263. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
  264. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
@@ -0,0 +1,2175 @@
1
+ import { describe, expect, test, afterEach, beforeEach } from "@jest/globals";
2
+
3
+ /*
4
+ * Extensive tests for the Microsoft Teams CHAT support (group chats +
5
+ * personal 1:1 chats) in MicrosoftTeamsUtil:
6
+ *
7
+ * - getChatDisplayName: pure name-resolution rules for captured chats.
8
+ * - saveChatToProjectAuthTokens / removeChatFromProjectAuthTokens: chats are
9
+ * stored in miscData.availableChats on EVERY WorkspaceProjectAuthToken row
10
+ * whose workspaceProjectId equals the Teams tenant id.
11
+ * - getChatsForProject: read side of the captured chat store.
12
+ * - handleConversationUpdateActivity / handleInstallationUpdateActivity: the
13
+ * Bot Framework events that capture (bot added) and forget (bot removed)
14
+ * chats.
15
+ * - sendAdaptiveCardToChat: proactive Bot Framework send into a stored chat.
16
+ * - sendMessage: the chatIds routing block (chunking, per-chat errors).
17
+ */
18
+
19
+ jest.mock("../../../../Server/EnvironmentConfig", () => {
20
+ return {
21
+ ...(jest.requireActual("../../../../Server/EnvironmentConfig") as Record<
22
+ string,
23
+ unknown
24
+ >),
25
+ MicrosoftTeamsAppClientId: "11111111-2222-3333-4444-555555555555",
26
+ MicrosoftTeamsAppClientSecret: "test-secret",
27
+ MicrosoftTeamsAppTenantId: "test-tenant",
28
+ };
29
+ });
30
+
31
+ /*
32
+ * The repo-wide botbuilder manual mock (Tests/__mocks__/botbuilder.js) does
33
+ * not expose TeamsInfo or MessageFactory.attachment, both of which the chat
34
+ * code paths use — so this file supplies its own richer factory.
35
+ */
36
+ jest.mock("botbuilder", () => {
37
+ return {
38
+ CloudAdapter: class CloudAdapter {},
39
+ ConfigurationBotFrameworkAuthentication: class ConfigurationBotFrameworkAuthentication {},
40
+ TeamsActivityHandler: class TeamsActivityHandler {},
41
+ TurnContext: class TurnContext {},
42
+ ActivityHandler: class ActivityHandler {},
43
+ MessageFactory: {
44
+ text: jest.fn(),
45
+ attachment: jest.fn((attachment: unknown) => {
46
+ return { type: "message", attachments: [attachment] };
47
+ }),
48
+ },
49
+ CardFactory: { heroCard: jest.fn() },
50
+ TeamsInfo: {
51
+ getMembers: jest.fn(),
52
+ getPagedMembers: jest.fn(),
53
+ },
54
+ };
55
+ });
56
+
57
+ import MicrosoftTeamsUtil from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
58
+ import WorkspaceProjectAuthTokenService from "../../../../Server/Services/WorkspaceProjectAuthTokenService";
59
+ import WorkspaceProjectAuthToken, {
60
+ MicrosoftTeamsChat,
61
+ MicrosoftTeamsMiscData,
62
+ } from "../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
63
+ import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
64
+ import ObjectID from "../../../../Types/ObjectID";
65
+ import LIMIT_MAX from "../../../../Types/Database/LimitMax";
66
+ import BadDataException from "../../../../Types/Exception/BadDataException";
67
+ import { JSONObject } from "../../../../Types/JSON";
68
+ import WorkspaceMessagePayload, {
69
+ WorkspacePayloadMarkdown,
70
+ } from "../../../../Types/Workspace/WorkspaceMessagePayload";
71
+ import {
72
+ WorkspaceSendMessageResponse,
73
+ WorkspaceThread,
74
+ } from "../../../../Server/Utils/Workspace/WorkspaceBase";
75
+ import {
76
+ TeamsInfo,
77
+ type ConversationReference,
78
+ type TeamsChannelAccount,
79
+ type TeamsPagedMembersResult,
80
+ type TurnContext,
81
+ } from "botbuilder";
82
+
83
+ const MOCK_APP_CLIENT_ID: string = "11111111-2222-3333-4444-555555555555";
84
+ const BOT_RECIPIENT_ID: string = "bot-recipient-id";
85
+ const TURN_CONTEXT_SERVICE_URL: string =
86
+ "https://smba.trafficmanager.net/emea/";
87
+
88
+ type ProactiveCallback = (context: TurnContext) => Promise<void>;
89
+
90
+ function buildTurnContext(data?: {
91
+ serviceUrl?: string | undefined;
92
+ recipientId?: string | undefined;
93
+ }): TurnContext {
94
+ return {
95
+ activity: {
96
+ recipient: { id: data?.recipientId || BOT_RECIPIENT_ID },
97
+ serviceUrl:
98
+ data && "serviceUrl" in data
99
+ ? data.serviceUrl
100
+ : TURN_CONTEXT_SERVICE_URL,
101
+ conversation: { id: "ctx-conversation-id" },
102
+ },
103
+ } as unknown as TurnContext;
104
+ }
105
+
106
+ function buildProjectAuthRow(data: {
107
+ id?: ObjectID | undefined;
108
+ workspaceProjectId?: string | undefined;
109
+ miscData?: MicrosoftTeamsMiscData | undefined;
110
+ }): WorkspaceProjectAuthToken {
111
+ return {
112
+ id: data.id || ObjectID.generate(),
113
+ workspaceProjectId: data.workspaceProjectId,
114
+ miscData: data.miscData,
115
+ } as unknown as WorkspaceProjectAuthToken;
116
+ }
117
+
118
+ function buildChat(data?: {
119
+ id?: string | undefined;
120
+ name?: string | undefined;
121
+ chatType?: "personal" | "groupChat" | undefined;
122
+ serviceUrl?: string | undefined;
123
+ }): MicrosoftTeamsChat {
124
+ const chat: MicrosoftTeamsChat = {
125
+ id: data?.id || "19:groupchat@thread.v2",
126
+ name: data?.name || "Alice, Bob",
127
+ chatType: data?.chatType || "groupChat",
128
+ addedAt: "2026-07-27T00:00:00.000Z",
129
+ };
130
+ if (data && "serviceUrl" in data) {
131
+ chat.serviceUrl = data.serviceUrl;
132
+ } else {
133
+ chat.serviceUrl = TURN_CONTEXT_SERVICE_URL;
134
+ }
135
+ return chat;
136
+ }
137
+
138
+ function baseMiscData(
139
+ overrides?: Partial<MicrosoftTeamsMiscData>,
140
+ ): MicrosoftTeamsMiscData {
141
+ return {
142
+ tenantId: "tenant-xyz",
143
+ teamId: "team-1",
144
+ teamName: "Engineering",
145
+ botId: "bot-1",
146
+ ...(overrides || {}),
147
+ } as MicrosoftTeamsMiscData;
148
+ }
149
+
150
+ function installFakeBotAdapter(options?: {
151
+ sendActivityResponse?: JSONObject | undefined;
152
+ }): Array<ConversationReference> {
153
+ const capturedRefs: Array<ConversationReference> = [];
154
+ const fakeAdapter: unknown = {
155
+ continueConversationAsync: async (
156
+ _appId: string,
157
+ ref: ConversationReference,
158
+ cb: ProactiveCallback,
159
+ ): Promise<void> => {
160
+ capturedRefs.push(ref);
161
+ const fakeContext: TurnContext = {
162
+ sendActivity: async (): Promise<JSONObject | undefined> => {
163
+ if (options && "sendActivityResponse" in options) {
164
+ return options.sendActivityResponse;
165
+ }
166
+ return { id: "msg-123" };
167
+ },
168
+ } as unknown as TurnContext;
169
+ await cb(fakeContext);
170
+ },
171
+ };
172
+ jest
173
+ .spyOn(MicrosoftTeamsUtil as any, "getBotAdapter")
174
+ .mockReturnValue(fakeAdapter);
175
+ return capturedRefs;
176
+ }
177
+
178
+ function mockMembers(members: Array<{ id: string; name?: string }>): void {
179
+ jest.spyOn(TeamsInfo, "getPagedMembers").mockResolvedValue({
180
+ members: members as Array<TeamsChannelAccount>,
181
+ continuationToken: undefined,
182
+ } as unknown as TeamsPagedMembersResult);
183
+ }
184
+
185
+ afterEach(() => {
186
+ jest.restoreAllMocks();
187
+ });
188
+
189
+ /*
190
+ * The botbuilder module mock's jest.fn()s are shared across tests —
191
+ * jest.spyOn on an existing mock returns the same function, so call history
192
+ * and mockResolvedValueOnce queues leak between tests unless reset here.
193
+ */
194
+ beforeEach(() => {
195
+ (TeamsInfo.getPagedMembers as jest.Mock).mockReset();
196
+ (TeamsInfo.getMembers as jest.Mock).mockReset();
197
+ });
198
+
199
+ describe("MicrosoftTeamsUtil.getChatDisplayName", () => {
200
+ test("topic wins over member names", () => {
201
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
202
+ chatType: "groupChat",
203
+ topic: "Ops War Room",
204
+ memberNames: ["Alice", "Bob"],
205
+ });
206
+ expect(name).toBe("Ops War Room");
207
+ });
208
+
209
+ test("long topics are truncated to 80 chars with an ellipsis (log columns are varchar(100))", () => {
210
+ const longTopic: string = "T".repeat(200);
211
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
212
+ chatType: "groupChat",
213
+ topic: longTopic,
214
+ memberNames: [],
215
+ });
216
+ expect(name.length).toBe(80);
217
+ expect(name.endsWith("…")).toBe(true);
218
+ expect(name.startsWith("TTTT")).toBe(true);
219
+ });
220
+
221
+ test("long synthesized member-name lists are truncated too", () => {
222
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
223
+ chatType: "groupChat",
224
+ memberNames: ["A".repeat(60), "B".repeat(60), "C".repeat(60)],
225
+ });
226
+ expect(name.length).toBeLessThanOrEqual(80);
227
+ expect(name.endsWith("…")).toBe(true);
228
+ });
229
+
230
+ test("80-char names are not truncated", () => {
231
+ const exact: string = "X".repeat(80);
232
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
233
+ chatType: "groupChat",
234
+ topic: exact,
235
+ memberNames: [],
236
+ });
237
+ expect(name).toBe(exact);
238
+ });
239
+
240
+ test("topic is trimmed before use", () => {
241
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
242
+ chatType: "groupChat",
243
+ topic: " Ops War Room ",
244
+ memberNames: ["Alice"],
245
+ });
246
+ expect(name).toBe("Ops War Room");
247
+ });
248
+
249
+ test("whitespace-only topic is ignored and members are used", () => {
250
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
251
+ chatType: "groupChat",
252
+ topic: " ",
253
+ memberNames: ["Alice", "Bob"],
254
+ });
255
+ expect(name).toBe("Alice, Bob");
256
+ });
257
+
258
+ test("personal chat uses the other member's name", () => {
259
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
260
+ chatType: "personal",
261
+ memberNames: ["Alice"],
262
+ });
263
+ expect(name).toBe("Alice");
264
+ });
265
+
266
+ test("personal chat with multiple member names uses only the first", () => {
267
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
268
+ chatType: "personal",
269
+ memberNames: ["Alice", "Bob"],
270
+ });
271
+ expect(name).toBe("Alice");
272
+ });
273
+
274
+ test("personal chat with no members falls back to 'Personal chat'", () => {
275
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
276
+ chatType: "personal",
277
+ memberNames: [],
278
+ });
279
+ expect(name).toBe("Personal chat");
280
+ });
281
+
282
+ test("personal chat with only blank member names falls back to 'Personal chat'", () => {
283
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
284
+ chatType: "personal",
285
+ memberNames: ["", " "],
286
+ });
287
+ expect(name).toBe("Personal chat");
288
+ });
289
+
290
+ test("group chat joins up to 3 names with a comma", () => {
291
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
292
+ chatType: "groupChat",
293
+ memberNames: ["Alice", "Bob", "Carol"],
294
+ });
295
+ expect(name).toBe("Alice, Bob, Carol");
296
+ });
297
+
298
+ test("group chat with 5 members shows 3 names + count of the rest", () => {
299
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
300
+ chatType: "groupChat",
301
+ memberNames: ["Alice", "Bob", "Carol", "Dave", "Erin"],
302
+ });
303
+ expect(name).toBe("Alice, Bob, Carol + 2 more");
304
+ });
305
+
306
+ test("group chat with 4 members shows '+ 1 more'", () => {
307
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
308
+ chatType: "groupChat",
309
+ memberNames: ["Alice", "Bob", "Carol", "Dave"],
310
+ });
311
+ expect(name).toBe("Alice, Bob, Carol + 1 more");
312
+ });
313
+
314
+ test("group chat with no members falls back to 'Group chat'", () => {
315
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
316
+ chatType: "groupChat",
317
+ memberNames: [],
318
+ });
319
+ expect(name).toBe("Group chat");
320
+ });
321
+
322
+ test("blank and empty member names are filtered out before counting", () => {
323
+ const name: string = MicrosoftTeamsUtil.getChatDisplayName({
324
+ chatType: "groupChat",
325
+ memberNames: ["", " ", "Alice", "", "Bob"],
326
+ });
327
+ expect(name).toBe("Alice, Bob");
328
+ });
329
+ });
330
+
331
+ describe("MicrosoftTeamsUtil.saveChatToProjectAuthTokens", () => {
332
+ test("queries project auth rows by MicrosoftTeams workspace type and tenant id", async () => {
333
+ const findBySpy: jest.SpyInstance = jest
334
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
335
+ .mockResolvedValue([]);
336
+
337
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
338
+ tenantId: "tenant-abc",
339
+ chat: buildChat(),
340
+ });
341
+
342
+ expect(findBySpy).toHaveBeenCalledTimes(1);
343
+ expect(findBySpy).toHaveBeenCalledWith(
344
+ expect.objectContaining({
345
+ query: {
346
+ workspaceType: WorkspaceType.MicrosoftTeams,
347
+ workspaceProjectId: "tenant-abc",
348
+ },
349
+ select: {
350
+ _id: true,
351
+ miscData: true,
352
+ },
353
+ limit: LIMIT_MAX,
354
+ skip: 0,
355
+ props: {
356
+ isRoot: true,
357
+ },
358
+ }),
359
+ );
360
+ });
361
+
362
+ test("updates EVERY matching project auth row (2 rows -> 2 updates)", async () => {
363
+ const rowIdOne: ObjectID = ObjectID.generate();
364
+ const rowIdTwo: ObjectID = ObjectID.generate();
365
+ jest
366
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
367
+ .mockResolvedValue([
368
+ buildProjectAuthRow({ id: rowIdOne, miscData: baseMiscData() }),
369
+ buildProjectAuthRow({ id: rowIdTwo, miscData: baseMiscData() }),
370
+ ]);
371
+ const updateSpy: jest.SpyInstance = jest
372
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
373
+ .mockResolvedValue(undefined as never);
374
+
375
+ const chat: MicrosoftTeamsChat = buildChat();
376
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
377
+ tenantId: "tenant-abc",
378
+ chat: chat,
379
+ });
380
+
381
+ expect(updateSpy).toHaveBeenCalledTimes(2);
382
+ const updatedIds: Array<string> = updateSpy.mock.calls.map(
383
+ (call: Array<unknown>) => {
384
+ return String((call[0] as { id: ObjectID }).id);
385
+ },
386
+ );
387
+ expect(updatedIds).toEqual([rowIdOne.toString(), rowIdTwo.toString()]);
388
+ for (const call of updateSpy.mock.calls) {
389
+ const args: { data: { miscData: MicrosoftTeamsMiscData } } =
390
+ call[0] as unknown as { data: { miscData: MicrosoftTeamsMiscData } };
391
+ expect(args.data.miscData.availableChats).toEqual({
392
+ [chat.id]: chat,
393
+ });
394
+ }
395
+ });
396
+
397
+ test("preserves existing miscData fields and existing chats", async () => {
398
+ const existingChat: MicrosoftTeamsChat = buildChat({
399
+ id: "19:existing@thread.v2",
400
+ name: "Existing chat",
401
+ });
402
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
403
+ buildProjectAuthRow({
404
+ miscData: baseMiscData({
405
+ appAccessToken: "token-abc",
406
+ availableChats: { [existingChat.id]: existingChat },
407
+ }),
408
+ }),
409
+ ]);
410
+ const updateSpy: jest.SpyInstance = jest
411
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
412
+ .mockResolvedValue(undefined as never);
413
+
414
+ const newChat: MicrosoftTeamsChat = buildChat({
415
+ id: "19:new@thread.v2",
416
+ name: "New chat",
417
+ });
418
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
419
+ tenantId: "tenant-abc",
420
+ chat: newChat,
421
+ });
422
+
423
+ expect(updateSpy).toHaveBeenCalledTimes(1);
424
+ const savedMiscData: MicrosoftTeamsMiscData = (
425
+ updateSpy.mock.calls[0]?.[0] as {
426
+ data: { miscData: MicrosoftTeamsMiscData };
427
+ }
428
+ ).data.miscData;
429
+ expect(savedMiscData.tenantId).toBe("tenant-xyz");
430
+ expect(savedMiscData.teamId).toBe("team-1");
431
+ expect(savedMiscData.teamName).toBe("Engineering");
432
+ expect(savedMiscData.botId).toBe("bot-1");
433
+ expect(savedMiscData.appAccessToken).toBe("token-abc");
434
+ expect(savedMiscData.availableChats).toEqual({
435
+ [existingChat.id]: existingChat,
436
+ [newChat.id]: newChat,
437
+ });
438
+ });
439
+
440
+ test("overwrites a chat that already exists under the same id (upsert)", async () => {
441
+ const staleChat: MicrosoftTeamsChat = buildChat({
442
+ id: "19:same@thread.v2",
443
+ name: "Old name",
444
+ });
445
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
446
+ buildProjectAuthRow({
447
+ miscData: baseMiscData({
448
+ availableChats: { [staleChat.id]: staleChat },
449
+ }),
450
+ }),
451
+ ]);
452
+ const updateSpy: jest.SpyInstance = jest
453
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
454
+ .mockResolvedValue(undefined as never);
455
+
456
+ const freshChat: MicrosoftTeamsChat = buildChat({
457
+ id: "19:same@thread.v2",
458
+ name: "New name",
459
+ });
460
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
461
+ tenantId: "tenant-abc",
462
+ chat: freshChat,
463
+ });
464
+
465
+ const savedMiscData: MicrosoftTeamsMiscData = (
466
+ updateSpy.mock.calls[0]?.[0] as {
467
+ data: { miscData: MicrosoftTeamsMiscData };
468
+ }
469
+ ).data.miscData;
470
+ expect(savedMiscData.availableChats).toEqual({
471
+ [freshChat.id]: freshChat,
472
+ });
473
+ expect(savedMiscData.availableChats?.[freshChat.id]?.name).toBe("New name");
474
+ });
475
+
476
+ test("creates availableChats when miscData had none (miscData undefined)", async () => {
477
+ jest
478
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
479
+ .mockResolvedValue([buildProjectAuthRow({ miscData: undefined })]);
480
+ const updateSpy: jest.SpyInstance = jest
481
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
482
+ .mockResolvedValue(undefined as never);
483
+
484
+ const chat: MicrosoftTeamsChat = buildChat();
485
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
486
+ tenantId: "tenant-abc",
487
+ chat: chat,
488
+ });
489
+
490
+ expect(updateSpy).toHaveBeenCalledTimes(1);
491
+ const savedMiscData: MicrosoftTeamsMiscData = (
492
+ updateSpy.mock.calls[0]?.[0] as {
493
+ data: { miscData: MicrosoftTeamsMiscData };
494
+ }
495
+ ).data.miscData;
496
+ expect(savedMiscData.availableChats).toEqual({ [chat.id]: chat });
497
+ });
498
+
499
+ test("no matching project auth rows -> no updates", async () => {
500
+ jest
501
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
502
+ .mockResolvedValue([]);
503
+ const updateSpy: jest.SpyInstance = jest
504
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
505
+ .mockResolvedValue(undefined as never);
506
+
507
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
508
+ tenantId: "tenant-abc",
509
+ chat: buildChat(),
510
+ });
511
+
512
+ expect(updateSpy).not.toHaveBeenCalled();
513
+ });
514
+
515
+ test("does not mutate the original row's miscData object", async () => {
516
+ const originalMiscData: MicrosoftTeamsMiscData = baseMiscData({
517
+ availableChats: {},
518
+ });
519
+ jest
520
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
521
+ .mockResolvedValue([buildProjectAuthRow({ miscData: originalMiscData })]);
522
+ jest
523
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
524
+ .mockResolvedValue(undefined as never);
525
+
526
+ await MicrosoftTeamsUtil.saveChatToProjectAuthTokens({
527
+ tenantId: "tenant-abc",
528
+ chat: buildChat(),
529
+ });
530
+
531
+ expect(originalMiscData.availableChats).toEqual({});
532
+ });
533
+ });
534
+
535
+ describe("MicrosoftTeamsUtil.removeChatFromProjectAuthTokens", () => {
536
+ test("removes only the target chat and keeps the others", async () => {
537
+ const chatToRemove: MicrosoftTeamsChat = buildChat({
538
+ id: "19:remove@thread.v2",
539
+ name: "Removed chat",
540
+ });
541
+ const chatToKeep: MicrosoftTeamsChat = buildChat({
542
+ id: "19:keep@thread.v2",
543
+ name: "Kept chat",
544
+ });
545
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
546
+ buildProjectAuthRow({
547
+ miscData: baseMiscData({
548
+ availableChats: {
549
+ [chatToRemove.id]: chatToRemove,
550
+ [chatToKeep.id]: chatToKeep,
551
+ },
552
+ }),
553
+ }),
554
+ ]);
555
+ const updateSpy: jest.SpyInstance = jest
556
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
557
+ .mockResolvedValue(undefined as never);
558
+
559
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
560
+ tenantId: "tenant-abc",
561
+ chatId: chatToRemove.id,
562
+ });
563
+
564
+ expect(updateSpy).toHaveBeenCalledTimes(1);
565
+ const savedMiscData: MicrosoftTeamsMiscData = (
566
+ updateSpy.mock.calls[0]?.[0] as {
567
+ data: { miscData: MicrosoftTeamsMiscData };
568
+ }
569
+ ).data.miscData;
570
+ expect(savedMiscData.availableChats).toEqual({
571
+ [chatToKeep.id]: chatToKeep,
572
+ });
573
+ expect(savedMiscData.botId).toBe("bot-1");
574
+ });
575
+
576
+ test("does not call updateOneById when the chat is absent", async () => {
577
+ const otherChat: MicrosoftTeamsChat = buildChat({
578
+ id: "19:other@thread.v2",
579
+ });
580
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
581
+ buildProjectAuthRow({
582
+ miscData: baseMiscData({
583
+ availableChats: { [otherChat.id]: otherChat },
584
+ }),
585
+ }),
586
+ ]);
587
+ const updateSpy: jest.SpyInstance = jest
588
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
589
+ .mockResolvedValue(undefined as never);
590
+
591
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
592
+ tenantId: "tenant-abc",
593
+ chatId: "19:not-there@thread.v2",
594
+ });
595
+
596
+ expect(updateSpy).not.toHaveBeenCalled();
597
+ });
598
+
599
+ test("handles rows with no availableChats at all", async () => {
600
+ jest
601
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
602
+ .mockResolvedValue([
603
+ buildProjectAuthRow({ miscData: baseMiscData() }),
604
+ buildProjectAuthRow({ miscData: undefined }),
605
+ ]);
606
+ const updateSpy: jest.SpyInstance = jest
607
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
608
+ .mockResolvedValue(undefined as never);
609
+
610
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
611
+ tenantId: "tenant-abc",
612
+ chatId: "19:whatever@thread.v2",
613
+ });
614
+
615
+ expect(updateSpy).not.toHaveBeenCalled();
616
+ });
617
+
618
+ test("removes the chat from every matching row (multi-row removal)", async () => {
619
+ const chat: MicrosoftTeamsChat = buildChat({ id: "19:multi@thread.v2" });
620
+ const rowIdOne: ObjectID = ObjectID.generate();
621
+ const rowIdTwo: ObjectID = ObjectID.generate();
622
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
623
+ buildProjectAuthRow({
624
+ id: rowIdOne,
625
+ miscData: baseMiscData({ availableChats: { [chat.id]: chat } }),
626
+ }),
627
+ buildProjectAuthRow({
628
+ id: rowIdTwo,
629
+ miscData: baseMiscData({ availableChats: { [chat.id]: chat } }),
630
+ }),
631
+ ]);
632
+ const updateSpy: jest.SpyInstance = jest
633
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
634
+ .mockResolvedValue(undefined as never);
635
+
636
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
637
+ tenantId: "tenant-abc",
638
+ chatId: chat.id,
639
+ });
640
+
641
+ expect(updateSpy).toHaveBeenCalledTimes(2);
642
+ for (const call of updateSpy.mock.calls) {
643
+ const args: { data: { miscData: MicrosoftTeamsMiscData } } =
644
+ call[0] as unknown as { data: { miscData: MicrosoftTeamsMiscData } };
645
+ expect(args.data.miscData.availableChats).toEqual({});
646
+ }
647
+ });
648
+
649
+ test("only rows that contain the chat are updated", async () => {
650
+ const chat: MicrosoftTeamsChat = buildChat({ id: "19:mixed@thread.v2" });
651
+ const rowWithChat: ObjectID = ObjectID.generate();
652
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue([
653
+ buildProjectAuthRow({
654
+ id: rowWithChat,
655
+ miscData: baseMiscData({ availableChats: { [chat.id]: chat } }),
656
+ }),
657
+ buildProjectAuthRow({
658
+ miscData: baseMiscData({ availableChats: {} }),
659
+ }),
660
+ ]);
661
+ const updateSpy: jest.SpyInstance = jest
662
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
663
+ .mockResolvedValue(undefined as never);
664
+
665
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
666
+ tenantId: "tenant-abc",
667
+ chatId: chat.id,
668
+ });
669
+
670
+ expect(updateSpy).toHaveBeenCalledTimes(1);
671
+ expect(String((updateSpy.mock.calls[0]?.[0] as { id: ObjectID }).id)).toBe(
672
+ rowWithChat.toString(),
673
+ );
674
+ });
675
+
676
+ test("does not mutate the original row's availableChats object", async () => {
677
+ const chat: MicrosoftTeamsChat = buildChat({ id: "19:immut@thread.v2" });
678
+ const originalMiscData: MicrosoftTeamsMiscData = baseMiscData({
679
+ availableChats: { [chat.id]: chat },
680
+ });
681
+ jest
682
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
683
+ .mockResolvedValue([buildProjectAuthRow({ miscData: originalMiscData })]);
684
+ jest
685
+ .spyOn(WorkspaceProjectAuthTokenService, "updateOneById")
686
+ .mockResolvedValue(undefined as never);
687
+
688
+ await MicrosoftTeamsUtil.removeChatFromProjectAuthTokens({
689
+ tenantId: "tenant-abc",
690
+ chatId: chat.id,
691
+ });
692
+
693
+ expect(originalMiscData.availableChats).toEqual({ [chat.id]: chat });
694
+ });
695
+ });
696
+
697
+ describe("MicrosoftTeamsUtil.getChatsForProject", () => {
698
+ test("returns empty object when there is no project auth", async () => {
699
+ jest
700
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
701
+ .mockResolvedValue(null);
702
+
703
+ const chats: Record<string, MicrosoftTeamsChat> =
704
+ await MicrosoftTeamsUtil.getChatsForProject({
705
+ projectId: ObjectID.generate(),
706
+ });
707
+
708
+ expect(chats).toEqual({});
709
+ });
710
+
711
+ test("returns empty object when project auth has no miscData", async () => {
712
+ jest
713
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
714
+ .mockResolvedValue(buildProjectAuthRow({ miscData: undefined }));
715
+
716
+ const chats: Record<string, MicrosoftTeamsChat> =
717
+ await MicrosoftTeamsUtil.getChatsForProject({
718
+ projectId: ObjectID.generate(),
719
+ });
720
+
721
+ expect(chats).toEqual({});
722
+ });
723
+
724
+ test("returns empty object when miscData has no availableChats", async () => {
725
+ jest
726
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
727
+ .mockResolvedValue(buildProjectAuthRow({ miscData: baseMiscData() }));
728
+
729
+ const chats: Record<string, MicrosoftTeamsChat> =
730
+ await MicrosoftTeamsUtil.getChatsForProject({
731
+ projectId: ObjectID.generate(),
732
+ });
733
+
734
+ expect(chats).toEqual({});
735
+ });
736
+
737
+ test("returns availableChats when present", async () => {
738
+ const chatOne: MicrosoftTeamsChat = buildChat({ id: "19:one@thread.v2" });
739
+ const chatTwo: MicrosoftTeamsChat = buildChat({
740
+ id: "a:1personal",
741
+ chatType: "personal",
742
+ name: "Alice",
743
+ });
744
+ jest
745
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
746
+ .mockResolvedValue(
747
+ buildProjectAuthRow({
748
+ miscData: baseMiscData({
749
+ availableChats: { [chatOne.id]: chatOne, [chatTwo.id]: chatTwo },
750
+ }),
751
+ }),
752
+ );
753
+
754
+ const chats: Record<string, MicrosoftTeamsChat> =
755
+ await MicrosoftTeamsUtil.getChatsForProject({
756
+ projectId: ObjectID.generate(),
757
+ });
758
+
759
+ expect(chats).toEqual({ [chatOne.id]: chatOne, [chatTwo.id]: chatTwo });
760
+ });
761
+
762
+ test("looks up project auth by projectId and MicrosoftTeams workspace type", async () => {
763
+ const getProjectAuthSpy: jest.SpyInstance = jest
764
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
765
+ .mockResolvedValue(null);
766
+ const projectId: ObjectID = ObjectID.generate();
767
+
768
+ await MicrosoftTeamsUtil.getChatsForProject({ projectId: projectId });
769
+
770
+ expect(getProjectAuthSpy).toHaveBeenCalledWith({
771
+ projectId: projectId,
772
+ workspaceType: WorkspaceType.MicrosoftTeams,
773
+ });
774
+ });
775
+ });
776
+
777
+ describe("MicrosoftTeamsUtil.handleConversationUpdateActivity", () => {
778
+ interface ConversationUpdateSpies {
779
+ saveSpy: jest.SpyInstance;
780
+ removeSpy: jest.SpyInstance;
781
+ welcomeSpy: jest.SpyInstance;
782
+ }
783
+
784
+ function installSpies(): ConversationUpdateSpies {
785
+ const saveSpy: jest.SpyInstance = jest
786
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
787
+ .mockResolvedValue(undefined as never);
788
+ const removeSpy: jest.SpyInstance = jest
789
+ .spyOn(MicrosoftTeamsUtil, "removeChatFromProjectAuthTokens")
790
+ .mockResolvedValue(undefined as never);
791
+ const welcomeSpy: jest.SpyInstance = jest
792
+ .spyOn(MicrosoftTeamsUtil as any, "sendWelcomeAdaptiveCard")
793
+ .mockResolvedValue(undefined as never);
794
+ return { saveSpy, removeSpy, welcomeSpy };
795
+ }
796
+
797
+ function groupChatBotAddedActivity(overrides?: JSONObject): JSONObject {
798
+ return {
799
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
800
+ conversation: {
801
+ conversationType: "groupChat",
802
+ id: "19:groupchat@thread.v2",
803
+ },
804
+ channelData: { tenant: { id: "tenant-1" } },
805
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
806
+ ...(overrides || {}),
807
+ };
808
+ }
809
+
810
+ test("bot added to a group chat saves the chat with the full shape", async () => {
811
+ const { saveSpy, welcomeSpy }: ConversationUpdateSpies = installSpies();
812
+ mockMembers([
813
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
814
+ { id: "user-1", name: "Alice" },
815
+ { id: "user-2", name: "Bob" },
816
+ ]);
817
+
818
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
819
+ activity: groupChatBotAddedActivity(),
820
+ turnContext: buildTurnContext(),
821
+ });
822
+
823
+ expect(saveSpy).toHaveBeenCalledTimes(1);
824
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
825
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
826
+ expect(saveArgs.tenantId).toBe("tenant-1");
827
+ expect(saveArgs.chat.id).toBe("19:groupchat@thread.v2");
828
+ expect(saveArgs.chat.chatType).toBe("groupChat");
829
+ // Bot member is filtered out of the name.
830
+ expect(saveArgs.chat.name).toBe("Alice, Bob");
831
+ // Service URL is taken from the activity itself when present.
832
+ expect(saveArgs.chat.serviceUrl).toBe(
833
+ "https://smba.trafficmanager.net/amer/",
834
+ );
835
+ expect(typeof saveArgs.chat.addedAt).toBe("string");
836
+ expect(
837
+ Number.isNaN(new Date(saveArgs.chat.addedAt as string).getTime()),
838
+ ).toBe(false);
839
+ expect(welcomeSpy).toHaveBeenCalledTimes(1);
840
+ });
841
+
842
+ test("bot added to a personal chat saves chatType personal with the other member's name", async () => {
843
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
844
+ mockMembers([
845
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
846
+ { id: "user-1", name: "Alice" },
847
+ ]);
848
+
849
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
850
+ activity: groupChatBotAddedActivity({
851
+ conversation: { conversationType: "personal", id: "a:1personal" },
852
+ }),
853
+ turnContext: buildTurnContext(),
854
+ });
855
+
856
+ expect(saveSpy).toHaveBeenCalledTimes(1);
857
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
858
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
859
+ expect(saveArgs.chat.chatType).toBe("personal");
860
+ expect(saveArgs.chat.name).toBe("Alice");
861
+ expect(saveArgs.chat.id).toBe("a:1personal");
862
+ });
863
+
864
+ test("bot added to a CHANNEL conversation saves nothing but still sends welcome", async () => {
865
+ const { saveSpy, welcomeSpy }: ConversationUpdateSpies = installSpies();
866
+ mockMembers([]);
867
+
868
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
869
+ activity: groupChatBotAddedActivity({
870
+ conversation: {
871
+ conversationType: "channel",
872
+ id: "19:channel@thread.tacv2",
873
+ },
874
+ }),
875
+ turnContext: buildTurnContext(),
876
+ });
877
+
878
+ expect(saveSpy).not.toHaveBeenCalled();
879
+ expect(welcomeSpy).toHaveBeenCalledTimes(1);
880
+ });
881
+
882
+ test("a non-bot member added saves nothing and sends no welcome", async () => {
883
+ const { saveSpy, removeSpy, welcomeSpy }: ConversationUpdateSpies =
884
+ installSpies();
885
+ mockMembers([]);
886
+
887
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
888
+ activity: groupChatBotAddedActivity({
889
+ membersAdded: [{ id: "some-human-user" }],
890
+ }),
891
+ turnContext: buildTurnContext(),
892
+ });
893
+
894
+ expect(saveSpy).not.toHaveBeenCalled();
895
+ expect(removeSpy).not.toHaveBeenCalled();
896
+ expect(welcomeSpy).not.toHaveBeenCalled();
897
+ });
898
+
899
+ test("bot removed from a group chat removes the stored chat", async () => {
900
+ const { saveSpy, removeSpy, welcomeSpy }: ConversationUpdateSpies =
901
+ installSpies();
902
+
903
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
904
+ activity: {
905
+ membersRemoved: [{ id: BOT_RECIPIENT_ID }],
906
+ conversation: {
907
+ conversationType: "groupChat",
908
+ id: "19:groupchat@thread.v2",
909
+ },
910
+ channelData: { tenant: { id: "tenant-1" } },
911
+ },
912
+ turnContext: buildTurnContext(),
913
+ });
914
+
915
+ expect(removeSpy).toHaveBeenCalledTimes(1);
916
+ expect(removeSpy).toHaveBeenCalledWith({
917
+ tenantId: "tenant-1",
918
+ chatId: "19:groupchat@thread.v2",
919
+ });
920
+ expect(saveSpy).not.toHaveBeenCalled();
921
+ expect(welcomeSpy).not.toHaveBeenCalled();
922
+ });
923
+
924
+ test("bot removed from a personal chat removes the stored chat", async () => {
925
+ const { removeSpy }: ConversationUpdateSpies = installSpies();
926
+
927
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
928
+ activity: {
929
+ membersRemoved: [{ id: BOT_RECIPIENT_ID }],
930
+ conversation: { conversationType: "personal", id: "a:1personal" },
931
+ channelData: { tenant: { id: "tenant-1" } },
932
+ },
933
+ turnContext: buildTurnContext(),
934
+ });
935
+
936
+ expect(removeSpy).toHaveBeenCalledWith({
937
+ tenantId: "tenant-1",
938
+ chatId: "a:1personal",
939
+ });
940
+ });
941
+
942
+ test("bot removed from a channel conversation does not touch stored chats", async () => {
943
+ const { removeSpy }: ConversationUpdateSpies = installSpies();
944
+
945
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
946
+ activity: {
947
+ membersRemoved: [{ id: BOT_RECIPIENT_ID }],
948
+ conversation: {
949
+ conversationType: "channel",
950
+ id: "19:channel@thread.tacv2",
951
+ },
952
+ channelData: { tenant: { id: "tenant-1" } },
953
+ },
954
+ turnContext: buildTurnContext(),
955
+ });
956
+
957
+ expect(removeSpy).not.toHaveBeenCalled();
958
+ });
959
+
960
+ test("missing tenant id -> nothing saved and no throw (welcome still sent)", async () => {
961
+ const { saveSpy, welcomeSpy }: ConversationUpdateSpies = installSpies();
962
+ mockMembers([]);
963
+
964
+ await expect(
965
+ MicrosoftTeamsUtil.handleConversationUpdateActivity({
966
+ activity: {
967
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
968
+ conversation: {
969
+ conversationType: "groupChat",
970
+ id: "19:groupchat@thread.v2",
971
+ },
972
+ // no channelData.tenant and no conversation.tenantId
973
+ },
974
+ turnContext: buildTurnContext(),
975
+ }),
976
+ ).resolves.toBeUndefined();
977
+
978
+ expect(saveSpy).not.toHaveBeenCalled();
979
+ expect(welcomeSpy).toHaveBeenCalledTimes(1);
980
+ });
981
+
982
+ test("missing conversation id -> nothing saved", async () => {
983
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
984
+ mockMembers([]);
985
+
986
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
987
+ activity: groupChatBotAddedActivity({
988
+ conversation: { conversationType: "groupChat" },
989
+ }),
990
+ turnContext: buildTurnContext(),
991
+ });
992
+
993
+ expect(saveSpy).not.toHaveBeenCalled();
994
+ });
995
+
996
+ test("TeamsInfo.getPagedMembers throwing still saves the chat with 'Group chat' fallback name", async () => {
997
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
998
+ jest
999
+ .spyOn(TeamsInfo, "getPagedMembers")
1000
+ .mockRejectedValue(new Error("members lookup failed"));
1001
+
1002
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1003
+ activity: groupChatBotAddedActivity(),
1004
+ turnContext: buildTurnContext(),
1005
+ });
1006
+
1007
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1008
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1009
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1010
+ expect(saveArgs.chat.name).toBe("Group chat");
1011
+ });
1012
+
1013
+ test("TeamsInfo.getPagedMembers throwing on a personal chat falls back to 'Personal chat'", async () => {
1014
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
1015
+ jest
1016
+ .spyOn(TeamsInfo, "getPagedMembers")
1017
+ .mockRejectedValue(new Error("members lookup failed"));
1018
+
1019
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1020
+ activity: groupChatBotAddedActivity({
1021
+ conversation: { conversationType: "personal", id: "a:1personal" },
1022
+ }),
1023
+ turnContext: buildTurnContext(),
1024
+ });
1025
+
1026
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1027
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1028
+ expect(saveArgs.chat.name).toBe("Personal chat");
1029
+ });
1030
+
1031
+ test("conversation.name (topic) takes priority over member names", async () => {
1032
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
1033
+ mockMembers([
1034
+ { id: "user-1", name: "Alice" },
1035
+ { id: "user-2", name: "Bob" },
1036
+ ]);
1037
+
1038
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1039
+ activity: groupChatBotAddedActivity({
1040
+ conversation: {
1041
+ conversationType: "groupChat",
1042
+ id: "19:groupchat@thread.v2",
1043
+ name: "Ops War Room",
1044
+ },
1045
+ }),
1046
+ turnContext: buildTurnContext(),
1047
+ });
1048
+
1049
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1050
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1051
+ expect(saveArgs.chat.name).toBe("Ops War Room");
1052
+ });
1053
+
1054
+ test("tenant id falls back to conversation.tenantId when channelData.tenant is missing", async () => {
1055
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
1056
+ mockMembers([]);
1057
+
1058
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1059
+ activity: {
1060
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
1061
+ conversation: {
1062
+ conversationType: "groupChat",
1063
+ id: "19:groupchat@thread.v2",
1064
+ tenantId: "tenant-2",
1065
+ },
1066
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1067
+ },
1068
+ turnContext: buildTurnContext(),
1069
+ });
1070
+
1071
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1072
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1073
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1074
+ expect(saveArgs.tenantId).toBe("tenant-2");
1075
+ });
1076
+
1077
+ test("service URL falls back to turnContext.activity.serviceUrl when missing from the activity", async () => {
1078
+ const { saveSpy }: ConversationUpdateSpies = installSpies();
1079
+ mockMembers([]);
1080
+
1081
+ const activity: JSONObject = groupChatBotAddedActivity();
1082
+ delete activity["serviceUrl"];
1083
+
1084
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
1085
+ activity: activity,
1086
+ turnContext: buildTurnContext(),
1087
+ });
1088
+
1089
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1090
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1091
+ expect(saveArgs.chat.serviceUrl).toBe(TURN_CONTEXT_SERVICE_URL);
1092
+ });
1093
+ });
1094
+
1095
+ describe("MicrosoftTeamsUtil.handleInstallationUpdateActivity", () => {
1096
+ interface InstallationSpies {
1097
+ saveSpy: jest.SpyInstance;
1098
+ removeSpy: jest.SpyInstance;
1099
+ welcomeSpy: jest.SpyInstance;
1100
+ }
1101
+
1102
+ function installSpies(): InstallationSpies {
1103
+ const saveSpy: jest.SpyInstance = jest
1104
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
1105
+ .mockResolvedValue(undefined as never);
1106
+ const removeSpy: jest.SpyInstance = jest
1107
+ .spyOn(MicrosoftTeamsUtil, "removeChatFromProjectAuthTokens")
1108
+ .mockResolvedValue(undefined as never);
1109
+ const welcomeSpy: jest.SpyInstance = jest
1110
+ .spyOn(MicrosoftTeamsUtil as any, "sendWelcomeAdaptiveCard")
1111
+ .mockResolvedValue(undefined as never);
1112
+ return { saveSpy, removeSpy, welcomeSpy };
1113
+ }
1114
+
1115
+ test("action 'add' on a group chat saves the chat (no welcome card here)", async () => {
1116
+ const { saveSpy, welcomeSpy }: InstallationSpies = installSpies();
1117
+ mockMembers([
1118
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
1119
+ { id: "user-1", name: "Alice" },
1120
+ ]);
1121
+
1122
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1123
+ activity: {
1124
+ action: "add",
1125
+ conversation: {
1126
+ conversationType: "groupChat",
1127
+ id: "19:installed@thread.v2",
1128
+ },
1129
+ channelData: { tenant: { id: "tenant-1" } },
1130
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1131
+ },
1132
+ turnContext: buildTurnContext(),
1133
+ });
1134
+
1135
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1136
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1137
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1138
+ expect(saveArgs.tenantId).toBe("tenant-1");
1139
+ expect(saveArgs.chat.id).toBe("19:installed@thread.v2");
1140
+ expect(saveArgs.chat.chatType).toBe("groupChat");
1141
+ expect(welcomeSpy).not.toHaveBeenCalled();
1142
+ });
1143
+
1144
+ test("action 'remove' removes the stored chat", async () => {
1145
+ const { saveSpy, removeSpy }: InstallationSpies = installSpies();
1146
+
1147
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1148
+ activity: {
1149
+ action: "remove",
1150
+ conversation: {
1151
+ conversationType: "groupChat",
1152
+ id: "19:installed@thread.v2",
1153
+ },
1154
+ channelData: { tenant: { id: "tenant-1" } },
1155
+ },
1156
+ turnContext: buildTurnContext(),
1157
+ });
1158
+
1159
+ expect(removeSpy).toHaveBeenCalledTimes(1);
1160
+ expect(removeSpy).toHaveBeenCalledWith({
1161
+ tenantId: "tenant-1",
1162
+ chatId: "19:installed@thread.v2",
1163
+ });
1164
+ expect(saveSpy).not.toHaveBeenCalled();
1165
+ });
1166
+
1167
+ test("action 'add-upgrade' (app upgraded in an already-installed chat) also saves the chat", async () => {
1168
+ const { saveSpy }: InstallationSpies = installSpies();
1169
+ mockMembers([
1170
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
1171
+ { id: "user-1", name: "Alice" },
1172
+ ]);
1173
+
1174
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1175
+ activity: {
1176
+ action: "add-upgrade",
1177
+ conversation: {
1178
+ conversationType: "groupChat",
1179
+ id: "19:upgraded@thread.v2",
1180
+ },
1181
+ channelData: { tenant: { id: "tenant-1" } },
1182
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1183
+ },
1184
+ turnContext: buildTurnContext(),
1185
+ });
1186
+
1187
+ expect(saveSpy).toHaveBeenCalledTimes(1);
1188
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
1189
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
1190
+ expect(saveArgs.chat.id).toBe("19:upgraded@thread.v2");
1191
+ });
1192
+
1193
+ test("action 'remove-upgrade' also removes the stored chat", async () => {
1194
+ const { removeSpy }: InstallationSpies = installSpies();
1195
+
1196
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1197
+ activity: {
1198
+ action: "remove-upgrade",
1199
+ conversation: {
1200
+ conversationType: "groupChat",
1201
+ id: "19:upgraded@thread.v2",
1202
+ },
1203
+ channelData: { tenant: { id: "tenant-1" } },
1204
+ },
1205
+ turnContext: buildTurnContext(),
1206
+ });
1207
+
1208
+ expect(removeSpy).toHaveBeenCalledWith({
1209
+ tenantId: "tenant-1",
1210
+ chatId: "19:upgraded@thread.v2",
1211
+ });
1212
+ });
1213
+
1214
+ test("action 'add' on a channel scope is ignored", async () => {
1215
+ const { saveSpy, removeSpy }: InstallationSpies = installSpies();
1216
+ mockMembers([]);
1217
+
1218
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1219
+ activity: {
1220
+ action: "add",
1221
+ conversation: {
1222
+ conversationType: "channel",
1223
+ id: "19:channel@thread.tacv2",
1224
+ },
1225
+ channelData: { tenant: { id: "tenant-1" } },
1226
+ },
1227
+ turnContext: buildTurnContext(),
1228
+ });
1229
+
1230
+ expect(saveSpy).not.toHaveBeenCalled();
1231
+ expect(removeSpy).not.toHaveBeenCalled();
1232
+ });
1233
+
1234
+ test("unknown action does nothing", async () => {
1235
+ const { saveSpy, removeSpy }: InstallationSpies = installSpies();
1236
+
1237
+ await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
1238
+ activity: {
1239
+ action: "update",
1240
+ conversation: {
1241
+ conversationType: "groupChat",
1242
+ id: "19:installed@thread.v2",
1243
+ },
1244
+ channelData: { tenant: { id: "tenant-1" } },
1245
+ },
1246
+ turnContext: buildTurnContext(),
1247
+ });
1248
+
1249
+ expect(saveSpy).not.toHaveBeenCalled();
1250
+ expect(removeSpy).not.toHaveBeenCalled();
1251
+ });
1252
+ });
1253
+
1254
+ describe("MicrosoftTeamsUtil.sendAdaptiveCardToChat", () => {
1255
+ const ADAPTIVE_CARD: JSONObject = {
1256
+ type: "AdaptiveCard",
1257
+ body: [],
1258
+ };
1259
+
1260
+ function mockProjectAuthWithChats(data?: {
1261
+ chats?: Record<string, MicrosoftTeamsChat> | undefined;
1262
+ workspaceProjectId?: string | undefined;
1263
+ miscData?: MicrosoftTeamsMiscData | null | undefined;
1264
+ }): void {
1265
+ let miscData: MicrosoftTeamsMiscData | undefined = undefined;
1266
+ if (data && "miscData" in data) {
1267
+ miscData = data.miscData === null ? undefined : data.miscData;
1268
+ } else {
1269
+ miscData = baseMiscData({ availableChats: data?.chats || {} });
1270
+ }
1271
+ jest
1272
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
1273
+ .mockResolvedValue(
1274
+ buildProjectAuthRow({
1275
+ workspaceProjectId:
1276
+ data && "workspaceProjectId" in data
1277
+ ? data.workspaceProjectId
1278
+ : "tenant-xyz",
1279
+ miscData: miscData,
1280
+ }),
1281
+ );
1282
+ }
1283
+
1284
+ test("group chat happy path builds the right conversation reference", async () => {
1285
+ const chat: MicrosoftTeamsChat = buildChat({
1286
+ id: "19:groupchat@thread.v2",
1287
+ name: "Alice, Bob",
1288
+ chatType: "groupChat",
1289
+ serviceUrl: TURN_CONTEXT_SERVICE_URL,
1290
+ });
1291
+ mockProjectAuthWithChats({ chats: { [chat.id]: chat } });
1292
+ const capturedRefs: Array<ConversationReference> = installFakeBotAdapter();
1293
+
1294
+ await MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1295
+ chatId: chat.id,
1296
+ projectId: ObjectID.generate(),
1297
+ adaptiveCard: ADAPTIVE_CARD,
1298
+ });
1299
+
1300
+ expect(capturedRefs).toHaveLength(1);
1301
+ const ref: ConversationReference = capturedRefs[0]!;
1302
+ expect(ref.conversation.id).toBe(chat.id);
1303
+ expect(ref.conversation.name).toBe("Alice, Bob");
1304
+ expect(ref.conversation.isGroup).toBe(true);
1305
+ expect(ref.conversation.conversationType).toBe("groupChat");
1306
+ expect(ref.conversation.tenantId).toBe("tenant-xyz");
1307
+ expect(ref.serviceUrl).toBe(TURN_CONTEXT_SERVICE_URL);
1308
+ expect(ref.channelId).toBe("msteams");
1309
+ expect(ref.bot.id).toBe(MOCK_APP_CLIENT_ID);
1310
+ });
1311
+
1312
+ test("returns a WorkspaceThread with the chat as channel and the sent message id", async () => {
1313
+ const chat: MicrosoftTeamsChat = buildChat({
1314
+ id: "19:groupchat@thread.v2",
1315
+ name: "Alice, Bob",
1316
+ });
1317
+ mockProjectAuthWithChats({ chats: { [chat.id]: chat } });
1318
+ installFakeBotAdapter();
1319
+
1320
+ const thread: WorkspaceThread =
1321
+ await MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1322
+ chatId: chat.id,
1323
+ projectId: ObjectID.generate(),
1324
+ adaptiveCard: ADAPTIVE_CARD,
1325
+ });
1326
+
1327
+ expect(thread).toEqual({
1328
+ channel: {
1329
+ id: chat.id,
1330
+ name: "Alice, Bob",
1331
+ workspaceType: WorkspaceType.MicrosoftTeams,
1332
+ },
1333
+ threadId: "msg-123",
1334
+ });
1335
+ });
1336
+
1337
+ test("personal chat sends with isGroup false and conversationType personal", async () => {
1338
+ const chat: MicrosoftTeamsChat = buildChat({
1339
+ id: "a:1personal",
1340
+ name: "Alice",
1341
+ chatType: "personal",
1342
+ });
1343
+ mockProjectAuthWithChats({ chats: { [chat.id]: chat } });
1344
+ const capturedRefs: Array<ConversationReference> = installFakeBotAdapter();
1345
+
1346
+ await MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1347
+ chatId: chat.id,
1348
+ projectId: ObjectID.generate(),
1349
+ adaptiveCard: ADAPTIVE_CARD,
1350
+ });
1351
+
1352
+ const ref: ConversationReference = capturedRefs[0]!;
1353
+ expect(ref.conversation.isGroup).toBe(false);
1354
+ expect(ref.conversation.conversationType).toBe("personal");
1355
+ });
1356
+
1357
+ test("chat without a stored serviceUrl falls back to the global Teams service URL", async () => {
1358
+ const chat: MicrosoftTeamsChat = buildChat({
1359
+ id: "19:nourl@thread.v2",
1360
+ serviceUrl: undefined,
1361
+ });
1362
+ mockProjectAuthWithChats({ chats: { [chat.id]: chat } });
1363
+ const capturedRefs: Array<ConversationReference> = installFakeBotAdapter();
1364
+
1365
+ await MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1366
+ chatId: chat.id,
1367
+ projectId: ObjectID.generate(),
1368
+ adaptiveCard: ADAPTIVE_CARD,
1369
+ });
1370
+
1371
+ expect(capturedRefs[0]!.serviceUrl).toBe(
1372
+ "https://smba.trafficmanager.net/teams/",
1373
+ );
1374
+ });
1375
+
1376
+ test("threadId is empty string when sendActivity returns no response", async () => {
1377
+ const chat: MicrosoftTeamsChat = buildChat({ id: "19:noresp@thread.v2" });
1378
+ mockProjectAuthWithChats({ chats: { [chat.id]: chat } });
1379
+ installFakeBotAdapter({ sendActivityResponse: undefined });
1380
+
1381
+ const thread: WorkspaceThread =
1382
+ await MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1383
+ chatId: chat.id,
1384
+ projectId: ObjectID.generate(),
1385
+ adaptiveCard: ADAPTIVE_CARD,
1386
+ });
1387
+
1388
+ expect(thread.threadId).toBe("");
1389
+ });
1390
+
1391
+ test("throws BadDataException when project auth is missing", async () => {
1392
+ jest
1393
+ .spyOn(WorkspaceProjectAuthTokenService, "getProjectAuth")
1394
+ .mockResolvedValue(null);
1395
+ installFakeBotAdapter();
1396
+
1397
+ await expect(
1398
+ MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1399
+ chatId: "19:any@thread.v2",
1400
+ projectId: ObjectID.generate(),
1401
+ adaptiveCard: ADAPTIVE_CARD,
1402
+ }),
1403
+ ).rejects.toThrow(
1404
+ new BadDataException(
1405
+ "Microsoft Teams integration not found for this project",
1406
+ ),
1407
+ );
1408
+ });
1409
+
1410
+ test("throws BadDataException when project auth has no miscData", async () => {
1411
+ mockProjectAuthWithChats({ miscData: null });
1412
+ installFakeBotAdapter();
1413
+
1414
+ await expect(
1415
+ MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1416
+ chatId: "19:any@thread.v2",
1417
+ projectId: ObjectID.generate(),
1418
+ adaptiveCard: ADAPTIVE_CARD,
1419
+ }),
1420
+ ).rejects.toThrow(
1421
+ new BadDataException(
1422
+ "Microsoft Teams integration not found for this project",
1423
+ ),
1424
+ );
1425
+ });
1426
+
1427
+ test("throws BadDataException when botId is missing from miscData", async () => {
1428
+ mockProjectAuthWithChats({
1429
+ miscData: baseMiscData({
1430
+ botId: undefined as unknown as string,
1431
+ availableChats: {},
1432
+ }),
1433
+ });
1434
+ installFakeBotAdapter();
1435
+
1436
+ await expect(
1437
+ MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1438
+ chatId: "19:any@thread.v2",
1439
+ projectId: ObjectID.generate(),
1440
+ adaptiveCard: ADAPTIVE_CARD,
1441
+ }),
1442
+ ).rejects.toThrow(
1443
+ new BadDataException("Bot ID not found in Microsoft Teams integration"),
1444
+ );
1445
+ });
1446
+
1447
+ test("throws BadDataException when tenant id is missing from project auth", async () => {
1448
+ mockProjectAuthWithChats({
1449
+ chats: {},
1450
+ workspaceProjectId: undefined,
1451
+ });
1452
+ installFakeBotAdapter();
1453
+
1454
+ await expect(
1455
+ MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1456
+ chatId: "19:any@thread.v2",
1457
+ projectId: ObjectID.generate(),
1458
+ adaptiveCard: ADAPTIVE_CARD,
1459
+ }),
1460
+ ).rejects.toThrow(
1461
+ new BadDataException(
1462
+ "Tenant ID not found in Microsoft Teams integration",
1463
+ ),
1464
+ );
1465
+ });
1466
+
1467
+ test("throws a helpful error when the chat id is not in availableChats", async () => {
1468
+ const otherChat: MicrosoftTeamsChat = buildChat({
1469
+ id: "19:other@thread.v2",
1470
+ });
1471
+ mockProjectAuthWithChats({ chats: { [otherChat.id]: otherChat } });
1472
+ installFakeBotAdapter();
1473
+
1474
+ await expect(
1475
+ MicrosoftTeamsUtil.sendAdaptiveCardToChat({
1476
+ chatId: "19:unknown@thread.v2",
1477
+ projectId: ObjectID.generate(),
1478
+ adaptiveCard: ADAPTIVE_CARD,
1479
+ }),
1480
+ ).rejects.toThrow(/add the OneUptime app/i);
1481
+ });
1482
+ });
1483
+
1484
+ describe("MicrosoftTeamsUtil.sendMessage - chat routing", () => {
1485
+ function buildChatPayload(data: {
1486
+ chatIds?: Array<string> | undefined;
1487
+ blockCount?: number | undefined;
1488
+ }): WorkspaceMessagePayload {
1489
+ const blocks: Array<WorkspacePayloadMarkdown> = [];
1490
+ const blockCount: number = data.blockCount ?? 1;
1491
+ for (let i: number = 0; i < blockCount; i++) {
1492
+ blocks.push({
1493
+ _type: "WorkspacePayloadMarkdown",
1494
+ text: `Block ${i}`,
1495
+ });
1496
+ }
1497
+ const payload: WorkspaceMessagePayload = {
1498
+ _type: "WorkspaceMessagePayload",
1499
+ channelNames: [],
1500
+ channelIds: [],
1501
+ messageBlocks: blocks,
1502
+ workspaceType: WorkspaceType.MicrosoftTeams,
1503
+ };
1504
+ if ("chatIds" in data) {
1505
+ payload.chatIds = data.chatIds;
1506
+ }
1507
+ return payload;
1508
+ }
1509
+
1510
+ function mockChatsForProject(
1511
+ chats: Record<string, MicrosoftTeamsChat>,
1512
+ ): jest.SpyInstance {
1513
+ return jest
1514
+ .spyOn(MicrosoftTeamsUtil, "getChatsForProject")
1515
+ .mockResolvedValue(chats);
1516
+ }
1517
+
1518
+ function mockSendCard(): jest.SpyInstance {
1519
+ return jest
1520
+ .spyOn(MicrosoftTeamsUtil, "sendAdaptiveCardToChat")
1521
+ .mockImplementation(
1522
+ async (args: {
1523
+ chatId: string;
1524
+ projectId: ObjectID;
1525
+ adaptiveCard: JSONObject;
1526
+ }): Promise<WorkspaceThread> => {
1527
+ return {
1528
+ channel: {
1529
+ id: args.chatId,
1530
+ name: `chat-name-${args.chatId}`,
1531
+ workspaceType: WorkspaceType.MicrosoftTeams,
1532
+ },
1533
+ threadId: `thread-${args.chatId}`,
1534
+ };
1535
+ },
1536
+ );
1537
+ }
1538
+
1539
+ const TWO_CHATS: Record<string, MicrosoftTeamsChat> = {
1540
+ "chat-1": buildChat({ id: "chat-1", name: "Ops group chat" }),
1541
+ "chat-2": buildChat({
1542
+ id: "chat-2",
1543
+ name: "Alice",
1544
+ chatType: "personal",
1545
+ }),
1546
+ };
1547
+
1548
+ test("payload with ONLY chatIds (no teamId, no channels) does not throw and sends one card per chat", async () => {
1549
+ mockChatsForProject(TWO_CHATS);
1550
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1551
+ const projectId: ObjectID = ObjectID.generate();
1552
+
1553
+ const response: WorkspaceSendMessageResponse =
1554
+ await MicrosoftTeamsUtil.sendMessage({
1555
+ workspaceMessagePayload: buildChatPayload({
1556
+ chatIds: ["chat-1", "chat-2"],
1557
+ }),
1558
+ authToken: "auth-token",
1559
+ userId: "user-1",
1560
+ projectId: projectId,
1561
+ });
1562
+
1563
+ expect(sendCardSpy).toHaveBeenCalledTimes(2);
1564
+ expect(sendCardSpy).toHaveBeenCalledWith(
1565
+ expect.objectContaining({ chatId: "chat-1", projectId: projectId }),
1566
+ );
1567
+ expect(sendCardSpy).toHaveBeenCalledWith(
1568
+ expect.objectContaining({ chatId: "chat-2", projectId: projectId }),
1569
+ );
1570
+ expect(response.workspaceType).toBe(WorkspaceType.MicrosoftTeams);
1571
+ expect(response.errors).toEqual([]);
1572
+ });
1573
+
1574
+ test("threads are collected per chat from sendAdaptiveCardToChat results", async () => {
1575
+ mockChatsForProject(TWO_CHATS);
1576
+ mockSendCard();
1577
+
1578
+ const response: WorkspaceSendMessageResponse =
1579
+ await MicrosoftTeamsUtil.sendMessage({
1580
+ workspaceMessagePayload: buildChatPayload({
1581
+ chatIds: ["chat-1", "chat-2"],
1582
+ }),
1583
+ authToken: "auth-token",
1584
+ userId: "user-1",
1585
+ projectId: ObjectID.generate(),
1586
+ });
1587
+
1588
+ expect(response.threads).toHaveLength(2);
1589
+ expect(response.threads).toEqual([
1590
+ {
1591
+ channel: {
1592
+ id: "chat-1",
1593
+ name: "chat-name-chat-1",
1594
+ workspaceType: WorkspaceType.MicrosoftTeams,
1595
+ },
1596
+ threadId: "thread-chat-1",
1597
+ },
1598
+ {
1599
+ channel: {
1600
+ id: "chat-2",
1601
+ name: "chat-name-chat-2",
1602
+ workspaceType: WorkspaceType.MicrosoftTeams,
1603
+ },
1604
+ threadId: "thread-chat-2",
1605
+ },
1606
+ ]);
1607
+ });
1608
+
1609
+ test("each send passes an AdaptiveCard payload built from the message blocks", async () => {
1610
+ mockChatsForProject(TWO_CHATS);
1611
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1612
+
1613
+ await MicrosoftTeamsUtil.sendMessage({
1614
+ workspaceMessagePayload: buildChatPayload({ chatIds: ["chat-1"] }),
1615
+ authToken: "auth-token",
1616
+ userId: "user-1",
1617
+ projectId: ObjectID.generate(),
1618
+ });
1619
+
1620
+ const sendArgs: { adaptiveCard: JSONObject } = sendCardSpy.mock
1621
+ .calls[0]?.[0] as { adaptiveCard: JSONObject };
1622
+ expect(sendArgs.adaptiveCard["type"]).toBe("AdaptiveCard");
1623
+ expect(Array.isArray(sendArgs.adaptiveCard["body"])).toBe(true);
1624
+ });
1625
+
1626
+ test("one chat failing records an error with the resolved chat name and the others still send", async () => {
1627
+ mockChatsForProject(TWO_CHATS);
1628
+ const sendCardSpy: jest.SpyInstance = jest
1629
+ .spyOn(MicrosoftTeamsUtil, "sendAdaptiveCardToChat")
1630
+ .mockImplementation(
1631
+ async (args: {
1632
+ chatId: string;
1633
+ projectId: ObjectID;
1634
+ adaptiveCard: JSONObject;
1635
+ }): Promise<WorkspaceThread> => {
1636
+ if (args.chatId === "chat-1") {
1637
+ throw new Error("proactive send failed");
1638
+ }
1639
+ return {
1640
+ channel: {
1641
+ id: args.chatId,
1642
+ name: `chat-name-${args.chatId}`,
1643
+ workspaceType: WorkspaceType.MicrosoftTeams,
1644
+ },
1645
+ threadId: `thread-${args.chatId}`,
1646
+ };
1647
+ },
1648
+ );
1649
+
1650
+ const response: WorkspaceSendMessageResponse =
1651
+ await MicrosoftTeamsUtil.sendMessage({
1652
+ workspaceMessagePayload: buildChatPayload({
1653
+ chatIds: ["chat-1", "chat-2"],
1654
+ }),
1655
+ authToken: "auth-token",
1656
+ userId: "user-1",
1657
+ projectId: ObjectID.generate(),
1658
+ });
1659
+
1660
+ expect(sendCardSpy).toHaveBeenCalledTimes(2);
1661
+ expect(response.errors).toEqual([
1662
+ {
1663
+ channel: {
1664
+ id: "chat-1",
1665
+ // Name resolved from the availableChats store, not the chat id.
1666
+ name: "Ops group chat",
1667
+ workspaceType: WorkspaceType.MicrosoftTeams,
1668
+ },
1669
+ error: "proactive send failed",
1670
+ },
1671
+ ]);
1672
+ expect(response.threads).toHaveLength(1);
1673
+ expect(response.threads[0]?.channel.id).toBe("chat-2");
1674
+ });
1675
+
1676
+ test("a failing chat id not present in availableChats falls back to the id as name", async () => {
1677
+ mockChatsForProject({});
1678
+ jest
1679
+ .spyOn(MicrosoftTeamsUtil, "sendAdaptiveCardToChat")
1680
+ .mockRejectedValue(new Error("not connected"));
1681
+
1682
+ const response: WorkspaceSendMessageResponse =
1683
+ await MicrosoftTeamsUtil.sendMessage({
1684
+ workspaceMessagePayload: buildChatPayload({
1685
+ chatIds: ["chat-unknown"],
1686
+ }),
1687
+ authToken: "auth-token",
1688
+ userId: "user-1",
1689
+ projectId: ObjectID.generate(),
1690
+ });
1691
+
1692
+ expect(response.errors).toEqual([
1693
+ {
1694
+ channel: {
1695
+ id: "chat-unknown",
1696
+ name: "chat-unknown",
1697
+ workspaceType: WorkspaceType.MicrosoftTeams,
1698
+ },
1699
+ error: "not connected",
1700
+ },
1701
+ ]);
1702
+ });
1703
+
1704
+ test("non-Error rejection is stringified into the error entry", async () => {
1705
+ mockChatsForProject(TWO_CHATS);
1706
+ jest
1707
+ .spyOn(MicrosoftTeamsUtil, "sendAdaptiveCardToChat")
1708
+ .mockRejectedValue("plain string failure" as never);
1709
+
1710
+ const response: WorkspaceSendMessageResponse =
1711
+ await MicrosoftTeamsUtil.sendMessage({
1712
+ workspaceMessagePayload: buildChatPayload({ chatIds: ["chat-1"] }),
1713
+ authToken: "auth-token",
1714
+ userId: "user-1",
1715
+ projectId: ObjectID.generate(),
1716
+ });
1717
+
1718
+ expect(response.errors).toHaveLength(1);
1719
+ expect(response.errors?.[0]?.error).toBe("plain string failure");
1720
+ });
1721
+
1722
+ test("more than 40 message blocks are chunked: one send per chunk per chat", async () => {
1723
+ mockChatsForProject(TWO_CHATS);
1724
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1725
+
1726
+ // 85 blocks -> chunks of 40, 40, 5 -> 3 cards per chat.
1727
+ const response: WorkspaceSendMessageResponse =
1728
+ await MicrosoftTeamsUtil.sendMessage({
1729
+ workspaceMessagePayload: buildChatPayload({
1730
+ chatIds: ["chat-1", "chat-2"],
1731
+ blockCount: 85,
1732
+ }),
1733
+ authToken: "auth-token",
1734
+ userId: "user-1",
1735
+ projectId: ObjectID.generate(),
1736
+ });
1737
+
1738
+ expect(sendCardSpy).toHaveBeenCalledTimes(6);
1739
+ const chatOneCalls: Array<unknown> = sendCardSpy.mock.calls.filter(
1740
+ (call: Array<unknown>) => {
1741
+ return (call[0] as { chatId: string }).chatId === "chat-1";
1742
+ },
1743
+ );
1744
+ expect(chatOneCalls).toHaveLength(3);
1745
+ // Only the LAST thread per chat is collected.
1746
+ expect(response.threads).toHaveLength(2);
1747
+ });
1748
+
1749
+ test("exactly 40 blocks stays a single card per chat", async () => {
1750
+ mockChatsForProject(TWO_CHATS);
1751
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1752
+
1753
+ await MicrosoftTeamsUtil.sendMessage({
1754
+ workspaceMessagePayload: buildChatPayload({
1755
+ chatIds: ["chat-1"],
1756
+ blockCount: 40,
1757
+ }),
1758
+ authToken: "auth-token",
1759
+ userId: "user-1",
1760
+ projectId: ObjectID.generate(),
1761
+ });
1762
+
1763
+ expect(sendCardSpy).toHaveBeenCalledTimes(1);
1764
+ });
1765
+
1766
+ test("41 blocks becomes two cards per chat", async () => {
1767
+ mockChatsForProject(TWO_CHATS);
1768
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1769
+
1770
+ await MicrosoftTeamsUtil.sendMessage({
1771
+ workspaceMessagePayload: buildChatPayload({
1772
+ chatIds: ["chat-1"],
1773
+ blockCount: 41,
1774
+ }),
1775
+ authToken: "auth-token",
1776
+ userId: "user-1",
1777
+ projectId: ObjectID.generate(),
1778
+ });
1779
+
1780
+ expect(sendCardSpy).toHaveBeenCalledTimes(2);
1781
+ });
1782
+
1783
+ test("empty chatIds array sends nothing and never loads the chat store", async () => {
1784
+ const chatsSpy: jest.SpyInstance = mockChatsForProject(TWO_CHATS);
1785
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1786
+
1787
+ const response: WorkspaceSendMessageResponse =
1788
+ await MicrosoftTeamsUtil.sendMessage({
1789
+ workspaceMessagePayload: buildChatPayload({ chatIds: [] }),
1790
+ authToken: "auth-token",
1791
+ userId: "user-1",
1792
+ projectId: ObjectID.generate(),
1793
+ });
1794
+
1795
+ expect(sendCardSpy).not.toHaveBeenCalled();
1796
+ expect(chatsSpy).not.toHaveBeenCalled();
1797
+ expect(response.threads).toEqual([]);
1798
+ expect(response.errors).toEqual([]);
1799
+ });
1800
+
1801
+ test("absent chatIds field sends nothing", async () => {
1802
+ const chatsSpy: jest.SpyInstance = mockChatsForProject(TWO_CHATS);
1803
+ const sendCardSpy: jest.SpyInstance = mockSendCard();
1804
+
1805
+ const response: WorkspaceSendMessageResponse =
1806
+ await MicrosoftTeamsUtil.sendMessage({
1807
+ workspaceMessagePayload: buildChatPayload({}),
1808
+ authToken: "auth-token",
1809
+ userId: "user-1",
1810
+ projectId: ObjectID.generate(),
1811
+ });
1812
+
1813
+ expect(sendCardSpy).not.toHaveBeenCalled();
1814
+ expect(chatsSpy).not.toHaveBeenCalled();
1815
+ expect(response.threads).toEqual([]);
1816
+ });
1817
+ });
1818
+
1819
+ describe("MicrosoftTeamsUtil.isChatCapturedForTenant", () => {
1820
+ type IsCapturedFn = (data: {
1821
+ tenantId: string;
1822
+ chatId: string;
1823
+ serviceUrl?: string | undefined;
1824
+ }) => Promise<boolean>;
1825
+
1826
+ const isCaptured: IsCapturedFn = (data: {
1827
+ tenantId: string;
1828
+ chatId: string;
1829
+ serviceUrl?: string | undefined;
1830
+ }): Promise<boolean> => {
1831
+ return (MicrosoftTeamsUtil as any).isChatCapturedForTenant(data);
1832
+ };
1833
+
1834
+ function mockAuthRows(
1835
+ rows: Array<Record<string, MicrosoftTeamsChat> | undefined>,
1836
+ ): void {
1837
+ jest.spyOn(WorkspaceProjectAuthTokenService, "findBy").mockResolvedValue(
1838
+ rows.map(
1839
+ (
1840
+ availableChats: Record<string, MicrosoftTeamsChat> | undefined,
1841
+ index: number,
1842
+ ) => {
1843
+ const auth: WorkspaceProjectAuthToken =
1844
+ new WorkspaceProjectAuthToken();
1845
+ auth._id = `auth-${index}`;
1846
+ auth.miscData = availableChats
1847
+ ? ({ availableChats: availableChats } as any)
1848
+ : ({} as any);
1849
+ return auth;
1850
+ },
1851
+ ) as Array<WorkspaceProjectAuthToken>,
1852
+ );
1853
+ }
1854
+
1855
+ test("true when every project row stores the chat with the same serviceUrl", async () => {
1856
+ mockAuthRows([
1857
+ {
1858
+ "19:x@thread.v2": {
1859
+ id: "19:x@thread.v2",
1860
+ name: "Chat",
1861
+ chatType: "groupChat",
1862
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1863
+ },
1864
+ },
1865
+ ]);
1866
+
1867
+ expect(
1868
+ await isCaptured({
1869
+ tenantId: "tenant-1",
1870
+ chatId: "19:x@thread.v2",
1871
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1872
+ }),
1873
+ ).toBe(true);
1874
+ });
1875
+
1876
+ test("false when the chat is missing from a project row", async () => {
1877
+ mockAuthRows([{}]);
1878
+
1879
+ expect(
1880
+ await isCaptured({
1881
+ tenantId: "tenant-1",
1882
+ chatId: "19:x@thread.v2",
1883
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1884
+ }),
1885
+ ).toBe(false);
1886
+ });
1887
+
1888
+ test("false when the stored serviceUrl is stale", async () => {
1889
+ mockAuthRows([
1890
+ {
1891
+ "19:x@thread.v2": {
1892
+ id: "19:x@thread.v2",
1893
+ name: "Chat",
1894
+ chatType: "groupChat",
1895
+ serviceUrl: "https://smba.trafficmanager.net/teams/",
1896
+ },
1897
+ },
1898
+ ]);
1899
+
1900
+ expect(
1901
+ await isCaptured({
1902
+ tenantId: "tenant-1",
1903
+ chatId: "19:x@thread.v2",
1904
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
1905
+ }),
1906
+ ).toBe(false);
1907
+ });
1908
+
1909
+ test("false when one of several project rows is missing the chat", async () => {
1910
+ const chat: MicrosoftTeamsChat = {
1911
+ id: "19:x@thread.v2",
1912
+ name: "Chat",
1913
+ chatType: "groupChat",
1914
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1915
+ };
1916
+ mockAuthRows([{ "19:x@thread.v2": chat }, {}]);
1917
+
1918
+ expect(
1919
+ await isCaptured({
1920
+ tenantId: "tenant-1",
1921
+ chatId: "19:x@thread.v2",
1922
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
1923
+ }),
1924
+ ).toBe(false);
1925
+ });
1926
+
1927
+ test("true when the tenant has no connected projects (nothing to capture into)", async () => {
1928
+ mockAuthRows([]);
1929
+
1930
+ expect(
1931
+ await isCaptured({
1932
+ tenantId: "tenant-unknown",
1933
+ chatId: "19:x@thread.v2",
1934
+ }),
1935
+ ).toBe(true);
1936
+ });
1937
+
1938
+ test("no serviceUrl on the activity skips the staleness comparison", async () => {
1939
+ mockAuthRows([
1940
+ {
1941
+ "19:x@thread.v2": {
1942
+ id: "19:x@thread.v2",
1943
+ name: "Chat",
1944
+ chatType: "groupChat",
1945
+ serviceUrl: "https://smba.trafficmanager.net/teams/",
1946
+ },
1947
+ },
1948
+ ]);
1949
+
1950
+ expect(
1951
+ await isCaptured({
1952
+ tenantId: "tenant-1",
1953
+ chatId: "19:x@thread.v2",
1954
+ }),
1955
+ ).toBe(true);
1956
+ });
1957
+ });
1958
+
1959
+ describe("MicrosoftTeamsUtil.handleBotMessageActivity - chat backfill", () => {
1960
+ /*
1961
+ * Chats installed before chat capture shipped fire no install events (a
1962
+ * manifest version bump alone sends no installationUpdate per Microsoft
1963
+ * docs) — inbound messages are the documented backfill path. These tests
1964
+ * use group-chat messages WITHOUT an @mention so the handler exits right
1965
+ * after the backfill step.
1966
+ */
1967
+
1968
+ function groupChatMessageActivity(overrides?: {
1969
+ conversation?: JSONObject;
1970
+ }): JSONObject {
1971
+ return {
1972
+ type: "message",
1973
+ text: "hello there",
1974
+ from: { id: "user-1", name: "Alice" },
1975
+ recipient: { id: BOT_RECIPIENT_ID },
1976
+ conversation: overrides?.conversation || {
1977
+ conversationType: "groupChat",
1978
+ id: "19:preexisting@thread.v2",
1979
+ },
1980
+ channelData: { tenant: { id: "tenant-1" } },
1981
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
1982
+ entities: [],
1983
+ };
1984
+ }
1985
+
1986
+ test("uncaptured group chat is backfilled when a user messages the bot", async () => {
1987
+ // isChatCapturedForTenant -> false (row without the chat)
1988
+ const authRow: WorkspaceProjectAuthToken = new WorkspaceProjectAuthToken();
1989
+ authRow._id = "auth-1";
1990
+ authRow.miscData = {} as any;
1991
+ jest
1992
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
1993
+ .mockResolvedValue([authRow]);
1994
+
1995
+ const saveSpy: jest.SpyInstance = jest
1996
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
1997
+ .mockResolvedValue(undefined as never);
1998
+ mockMembers([
1999
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
2000
+ { id: "user-1", name: "Alice" },
2001
+ { id: "user-2", name: "Bob" },
2002
+ ]);
2003
+
2004
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
2005
+ activity: groupChatMessageActivity(),
2006
+ turnContext: buildTurnContext({
2007
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
2008
+ }),
2009
+ });
2010
+
2011
+ expect(saveSpy).toHaveBeenCalledTimes(1);
2012
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
2013
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
2014
+ expect(saveArgs.tenantId).toBe("tenant-1");
2015
+ expect(saveArgs.chat.id).toBe("19:preexisting@thread.v2");
2016
+ expect(saveArgs.chat.chatType).toBe("groupChat");
2017
+ expect(saveArgs.chat.serviceUrl).toBe(
2018
+ "https://smba.trafficmanager.net/emea/",
2019
+ );
2020
+ expect(saveArgs.chat.name).toBe("Alice, Bob");
2021
+ });
2022
+
2023
+ test("already-captured chat with a fresh serviceUrl is not re-saved and no roster call is made", async () => {
2024
+ const authRow: WorkspaceProjectAuthToken = new WorkspaceProjectAuthToken();
2025
+ authRow._id = "auth-1";
2026
+ authRow.miscData = {
2027
+ availableChats: {
2028
+ "19:preexisting@thread.v2": {
2029
+ id: "19:preexisting@thread.v2",
2030
+ name: "Alice, Bob",
2031
+ chatType: "groupChat",
2032
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
2033
+ },
2034
+ },
2035
+ } as any;
2036
+ jest
2037
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
2038
+ .mockResolvedValue([authRow]);
2039
+
2040
+ const saveSpy: jest.SpyInstance = jest
2041
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
2042
+ .mockResolvedValue(undefined as never);
2043
+ const rosterSpy: jest.SpyInstance = jest.spyOn(
2044
+ TeamsInfo,
2045
+ "getPagedMembers",
2046
+ );
2047
+
2048
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
2049
+ activity: groupChatMessageActivity(),
2050
+ turnContext: buildTurnContext({
2051
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
2052
+ }),
2053
+ });
2054
+
2055
+ expect(saveSpy).not.toHaveBeenCalled();
2056
+ expect(rosterSpy).not.toHaveBeenCalled();
2057
+ });
2058
+
2059
+ test("captured chat with a STALE serviceUrl is refreshed", async () => {
2060
+ const authRow: WorkspaceProjectAuthToken = new WorkspaceProjectAuthToken();
2061
+ authRow._id = "auth-1";
2062
+ authRow.miscData = {
2063
+ availableChats: {
2064
+ "19:preexisting@thread.v2": {
2065
+ id: "19:preexisting@thread.v2",
2066
+ name: "Alice, Bob",
2067
+ chatType: "groupChat",
2068
+ serviceUrl: "https://smba.trafficmanager.net/OLD/",
2069
+ },
2070
+ },
2071
+ } as any;
2072
+ jest
2073
+ .spyOn(WorkspaceProjectAuthTokenService, "findBy")
2074
+ .mockResolvedValue([authRow]);
2075
+
2076
+ const saveSpy: jest.SpyInstance = jest
2077
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
2078
+ .mockResolvedValue(undefined as never);
2079
+ mockMembers([{ id: "user-1", name: "Alice" }]);
2080
+
2081
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
2082
+ activity: groupChatMessageActivity(),
2083
+ turnContext: buildTurnContext({
2084
+ serviceUrl: "https://smba.trafficmanager.net/emea/",
2085
+ }),
2086
+ });
2087
+
2088
+ expect(saveSpy).toHaveBeenCalledTimes(1);
2089
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
2090
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
2091
+ expect(saveArgs.chat.serviceUrl).toBe(
2092
+ "https://smba.trafficmanager.net/emea/",
2093
+ );
2094
+ });
2095
+
2096
+ test("channel messages trigger no backfill", async () => {
2097
+ const captureSpy: jest.SpyInstance = jest.spyOn(
2098
+ MicrosoftTeamsUtil as any,
2099
+ "captureChatFromBotActivity",
2100
+ );
2101
+
2102
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
2103
+ activity: groupChatMessageActivity({
2104
+ conversation: {
2105
+ conversationType: "channel",
2106
+ id: "19:channel@thread.tacv2",
2107
+ },
2108
+ }),
2109
+ turnContext: buildTurnContext(),
2110
+ });
2111
+
2112
+ expect(captureSpy).not.toHaveBeenCalled();
2113
+ });
2114
+
2115
+ test("bot's own messages trigger no backfill (loop guard runs first)", async () => {
2116
+ const captureSpy: jest.SpyInstance = jest.spyOn(
2117
+ MicrosoftTeamsUtil as any,
2118
+ "captureChatFromBotActivity",
2119
+ );
2120
+
2121
+ const activity: JSONObject = groupChatMessageActivity();
2122
+ (activity["from"] as JSONObject)["id"] = BOT_RECIPIENT_ID;
2123
+
2124
+ await MicrosoftTeamsUtil.handleBotMessageActivity({
2125
+ activity: activity,
2126
+ turnContext: buildTurnContext(),
2127
+ });
2128
+
2129
+ expect(captureSpy).not.toHaveBeenCalled();
2130
+ });
2131
+ });
2132
+
2133
+ describe("MicrosoftTeamsUtil roster pagination (getPagedMembers)", () => {
2134
+ test("accumulates member names across continuation pages", async () => {
2135
+ const saveSpy: jest.SpyInstance = jest
2136
+ .spyOn(MicrosoftTeamsUtil, "saveChatToProjectAuthTokens")
2137
+ .mockResolvedValue(undefined as never);
2138
+ jest
2139
+ .spyOn(MicrosoftTeamsUtil as any, "sendWelcomeAdaptiveCard")
2140
+ .mockResolvedValue(undefined as never);
2141
+
2142
+ const pagedSpy: jest.SpyInstance = jest
2143
+ .spyOn(TeamsInfo, "getPagedMembers")
2144
+ .mockResolvedValueOnce({
2145
+ members: [
2146
+ { id: BOT_RECIPIENT_ID, name: "OneUptime" },
2147
+ { id: "user-1", name: "Alice" },
2148
+ ],
2149
+ continuationToken: "page-2",
2150
+ } as unknown as TeamsPagedMembersResult)
2151
+ .mockResolvedValueOnce({
2152
+ members: [{ id: "user-2", name: "Bob" }],
2153
+ continuationToken: undefined,
2154
+ } as unknown as TeamsPagedMembersResult);
2155
+
2156
+ await MicrosoftTeamsUtil.handleConversationUpdateActivity({
2157
+ activity: {
2158
+ membersAdded: [{ id: BOT_RECIPIENT_ID }],
2159
+ membersRemoved: [],
2160
+ conversation: {
2161
+ conversationType: "groupChat",
2162
+ id: "19:paged@thread.v2",
2163
+ },
2164
+ channelData: { tenant: { id: "tenant-1" } },
2165
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
2166
+ },
2167
+ turnContext: buildTurnContext(),
2168
+ });
2169
+
2170
+ expect(pagedSpy).toHaveBeenCalledTimes(2);
2171
+ const saveArgs: { tenantId: string; chat: MicrosoftTeamsChat } = saveSpy
2172
+ .mock.calls[0]?.[0] as { tenantId: string; chat: MicrosoftTeamsChat };
2173
+ expect(saveArgs.chat.name).toBe("Alice, Bob");
2174
+ });
2175
+ });