@oneuptime/common 11.7.1 → 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.
- package/Models/DatabaseModels/DashboardDomain.ts +1 -0
- package/Models/DatabaseModels/Incident.ts +2 -0
- package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
- package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
- package/Models/DatabaseModels/Monitor.ts +1 -0
- package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/NetworkSite.ts +1 -0
- package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
- package/Models/DatabaseModels/Probe.ts +13 -13
- package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
- package/Models/DatabaseModels/StatusPage.ts +1 -0
- package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
- package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
- package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
- package/Server/API/BaseAPI.ts +17 -1
- package/Server/API/CommonAPI.ts +32 -0
- package/Server/API/MicrosoftTeamsAPI.ts +99 -2
- package/Server/API/ProbeAPI.ts +2 -0
- package/Server/API/SlackAPI.ts +62 -0
- package/Server/API/StatusPageAPI.ts +157 -192
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Services/DatabaseService.ts +11 -3
- package/Server/Services/MonitorGroupService.ts +145 -0
- package/Server/Services/MonitorService.ts +231 -30
- package/Server/Services/ProbeService.ts +319 -16
- package/Server/Services/StatusPageService.ts +72 -42
- package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
- package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
- package/Server/Utils/Monitor/MonitorResource.ts +88 -35
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
- package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
- package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
- package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
- package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
- package/Tests/Server/API/ProbeAPI.test.ts +6 -0
- package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
- package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
- package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
- package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
- package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
- package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
- package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
- package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
- package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
- package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
- package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
- package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
- package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
- package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
- package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
- package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
- package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
- package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
- package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
- package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
- package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
- package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
- package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
- package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
- package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
- package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
- package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
- package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
- package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
- package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
- package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
- package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
- package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
- package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
- package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
- package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
- package/Tests/UI/Utils/Project.test.ts +809 -0
- package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
- package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
- package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
- package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
- package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
- package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
- package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
- package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
- package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
- package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
- package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
- package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
- package/UI/Components/Forms/ModelForm.tsx +41 -18
- package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
- package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
- package/UI/Components/ModelTable/Column.ts +12 -0
- package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
- package/UI/Components/Navbar/NavBar.tsx +66 -1
- package/UI/Components/Table/Table.tsx +52 -13
- package/UI/Components/Table/Types/Column.ts +18 -0
- package/UI/Utils/Project.ts +214 -18
- package/UI/Utils/TableColumnsToCsv.ts +86 -6
- package/UI/esbuild-config.js +34 -0
- package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Incident.js +2 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
- package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Probe.js +13 -13
- package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +12 -1
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/CommonAPI.js +21 -0
- package/build/dist/Server/API/CommonAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/ProbeAPI.js +2 -0
- package/build/dist/Server/API/ProbeAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +44 -0
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +98 -152
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +10 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +118 -0
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +198 -24
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ProbeService.js +238 -9
- package/build/dist/Server/Services/ProbeService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +46 -39
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
- package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +31 -11
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Utils/Project.js +161 -15
- package/build/dist/UI/Utils/Project.js.map +1 -1
- package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
- package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
- package/package.json +4 -3
|
@@ -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
|
+
});
|