@openclaw/msteams 2026.3.13 → 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +138 -1
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +163 -418
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +145 -4
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +161 -4
  78. package/src/graph-upload.ts +147 -56
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +504 -23
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +470 -164
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +281 -79
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +11 -5
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -107
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -1,17 +1,12 @@
1
- import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk/msteams";
2
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
4
- import {
5
- buildMSTeamsAttachmentPlaceholder,
6
- buildMSTeamsGraphMessageUrls,
7
- buildMSTeamsMediaPayload,
8
- downloadMSTeamsAttachments,
9
- downloadMSTeamsGraphMedia,
10
- } from "./attachments.js";
2
+ import type { PluginRuntime, SsrFPolicy } from "../runtime-api.js";
3
+ import { readRemoteMediaResponse } from "./attachments.test-helpers.js";
4
+ import { downloadMSTeamsAttachments } from "./attachments/download.js";
5
+ import { resolveRequestUrl } from "./attachments/shared.js";
11
6
  import { setMSTeamsRuntime } from "./runtime.js";
12
7
 
13
8
  const GRAPH_HOST = "graph.microsoft.com";
14
- const SHAREPOINT_HOST = "contoso.sharepoint.com";
9
+ const _SHAREPOINT_HOST = "contoso.sharepoint.com";
15
10
  const AZUREEDGE_HOST = "azureedge.net";
16
11
  const TEST_HOST = "x";
17
12
  const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`;
@@ -19,14 +14,14 @@ const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathS
19
14
  const SAVED_PNG_PATH = "/tmp/saved.png";
20
15
  const SAVED_PDF_PATH = "/tmp/saved.pdf";
21
16
  const TEST_URL_IMAGE = createTestUrl("img");
22
- const TEST_URL_IMAGE_PNG = createTestUrl("img.png");
23
- const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
24
- const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
25
- const TEST_URL_PDF = createTestUrl("x.pdf");
26
- const TEST_URL_PDF_1 = createTestUrl("1.pdf");
27
- const TEST_URL_PDF_2 = createTestUrl("2.pdf");
28
- const TEST_URL_HTML_A = createTestUrl("a.png");
29
- const TEST_URL_HTML_B = createTestUrl("b.png");
17
+ const _TEST_URL_IMAGE_PNG = createTestUrl("img.png");
18
+ const _TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
19
+ const _TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
20
+ const _TEST_URL_PDF = createTestUrl("x.pdf");
21
+ const _TEST_URL_PDF_1 = createTestUrl("1.pdf");
22
+ const _TEST_URL_PDF_2 = createTestUrl("2.pdf");
23
+ const _TEST_URL_HTML_A = createTestUrl("a.png");
24
+ const _TEST_URL_HTML_B = createTestUrl("b.png");
30
25
  const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png");
31
26
  const TEST_URL_DOC_PDF = createTestUrl("doc.pdf");
32
27
  const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl");
@@ -35,7 +30,7 @@ const CONTENT_TYPE_IMAGE_PNG = "image/png";
35
30
  const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
36
31
  const CONTENT_TYPE_TEXT_HTML = "text/html";
37
32
  const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
38
- const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
33
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
39
34
  const MAX_REDIRECT_HOPS = 5;
40
35
  type RemoteMediaFetchParams = {
41
36
  url: string;
@@ -52,24 +47,6 @@ const saveMediaBufferMock = vi.fn(async () => ({
52
47
  size: Buffer.byteLength(PNG_BUFFER),
53
48
  contentType: CONTENT_TYPE_IMAGE_PNG,
54
49
  }));
55
- const readRemoteMediaResponse = async (
56
- res: Response,
57
- params: Pick<RemoteMediaFetchParams, "maxBytes" | "filePathHint">,
58
- ) => {
59
- if (!res.ok) {
60
- throw new Error(`HTTP ${res.status}`);
61
- }
62
- const buffer = Buffer.from(await res.arrayBuffer());
63
- if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
64
- throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
65
- }
66
- return {
67
- buffer,
68
- contentType: res.headers.get("content-type") ?? undefined,
69
- fileName: params.filePathHint,
70
- };
71
- };
72
-
73
50
  function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
74
51
  if (pattern.startsWith("*.")) {
75
52
  const suffix = pattern.slice(2);
@@ -99,7 +76,7 @@ async function fetchRemoteMediaWithRedirects(
99
76
  throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
100
77
  }
101
78
  const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit });
102
- if (REDIRECT_STATUS_CODES.includes(res.status)) {
79
+ if (REDIRECT_STATUS_CODES.has(res.status)) {
103
80
  const location = res.headers.get("location");
104
81
  if (!location) {
105
82
  throw new Error("redirect missing location");
@@ -116,7 +93,7 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
116
93
  return await fetchRemoteMediaWithRedirects(params);
117
94
  });
118
95
 
119
- const runtimeStub: PluginRuntime = createPluginRuntimeMock({
96
+ const runtimeStub = {
120
97
  media: {
121
98
  detectMime: detectMimeMock,
122
99
  },
@@ -126,12 +103,10 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
126
103
  saveMediaBuffer: saveMediaBufferMock,
127
104
  },
128
105
  },
129
- });
106
+ } as unknown as PluginRuntime;
130
107
 
131
108
  type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
132
- type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
133
109
  type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
134
- type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
135
110
  type DownloadAttachmentsBuildOverrides = Partial<
136
111
  Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
137
112
  > &
@@ -140,31 +115,17 @@ type DownloadAttachmentsNoFetchOverrides = Partial<
140
115
  Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
141
116
  > &
142
117
  Pick<DownloadAttachmentsParams, "allowHosts">;
143
- type DownloadGraphMediaOverrides = Partial<
144
- Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
145
- >;
146
118
  type FetchFn = typeof fetch;
147
119
  type MSTeamsAttachments = DownloadAttachmentsParams["attachments"];
148
- type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
149
- type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
150
120
  type LabeledCase = { label: string };
151
121
  type FetchCallExpectation = { expectFetchCalled?: boolean };
152
122
  type DownloadedMediaExpectation = { path?: string; placeholder?: string };
153
- type MSTeamsMediaPayloadExpectation = {
154
- firstPath: string;
155
- paths: string[];
156
- types: string[];
157
- };
158
123
 
159
- const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`;
160
- const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
161
124
  const DEFAULT_MAX_BYTES = 1024 * 1024;
162
125
  const DEFAULT_ALLOW_HOSTS = [TEST_HOST];
163
- const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST];
164
- const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file");
165
126
  const MEDIA_PLACEHOLDER_IMAGE = "<media:image>";
166
127
  const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>";
167
- const formatImagePlaceholder = (count: number) =>
128
+ const _formatImagePlaceholder = (count: number) =>
168
129
  count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE;
169
130
  const formatDocumentPlaceholder = (count: number) =>
170
131
  count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT;
@@ -208,19 +169,16 @@ const createTeamsFileDownloadInfoAttachments = (
208
169
  content: { downloadUrl, fileType },
209
170
  }),
210
171
  );
211
- const createMediaEntriesWithType = (contentType: string, ...paths: string[]) =>
212
- paths.map((path) => ({ path, contentType }));
213
172
  const createHostedContentsWithType = (contentType: string, ...ids: string[]) =>
214
173
  ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 }));
215
- const createImageMediaEntries = (...paths: string[]) =>
216
- createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
217
- const createHostedImageContents = (...ids: string[]) =>
174
+ const _createHostedImageContents = (...ids: string[]) =>
218
175
  createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids);
219
- const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => {
176
+ type BinaryPayload = Uint8Array | string;
177
+ const _createPdfResponse = (payload: BinaryPayload = PDF_BUFFER) => {
220
178
  return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF);
221
179
  };
222
- const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => {
223
- const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
180
+ const createBufferResponse = (payload: BinaryPayload, contentType: string, status = 200) => {
181
+ const raw = typeof payload === "string" ? Buffer.from(payload) : payload;
224
182
  return new Response(new Uint8Array(raw), {
225
183
  status,
226
184
  headers: { "content-type": contentType },
@@ -229,13 +187,16 @@ const createBufferResponse = (payload: Buffer | string, contentType: string, sta
229
187
  const createJsonResponse = (payload: unknown, status = 200) =>
230
188
  new Response(JSON.stringify(payload), { status });
231
189
  const createTextResponse = (body: string, status = 200) => new Response(body, { status });
232
- const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
190
+ const _createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
233
191
  const createNotFoundResponse = () => new Response("not found", { status: 404 });
234
192
  const createRedirectResponse = (location: string, status = 302) =>
235
193
  new Response(null, { status, headers: { location } });
194
+ const publicResolve = async () => ({ address: "13.107.136.10" });
236
195
 
237
196
  const createOkFetchMock = (contentType: string, payload = "png") =>
238
- vi.fn(async () => createBufferResponse(payload, contentType));
197
+ vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
198
+ createBufferResponse(payload, contentType),
199
+ );
239
200
  const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn;
240
201
 
241
202
  const buildDownloadParams = (
@@ -246,6 +207,7 @@ const buildDownloadParams = (
246
207
  attachments,
247
208
  maxBytes: DEFAULT_MAX_BYTES,
248
209
  allowHosts: DEFAULT_ALLOW_HOSTS,
210
+ resolveFn: publicResolve,
249
211
  ...overrides,
250
212
  };
251
213
  };
@@ -283,25 +245,6 @@ const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => {
283
245
  }
284
246
  };
285
247
 
286
- const DEFAULT_CHANNEL_TEAM_ID = "team-id";
287
- const DEFAULT_CHANNEL_ID = "chan-id";
288
- const createChannelGraphMessageUrlParams = (params: {
289
- messageId: string;
290
- replyToId?: string;
291
- conversationId?: string;
292
- }) => ({
293
- conversationType: "channel" as const,
294
- ...params,
295
- channelData: {
296
- team: { id: DEFAULT_CHANNEL_TEAM_ID },
297
- channel: { id: DEFAULT_CHANNEL_ID },
298
- },
299
- });
300
- const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) =>
301
- params.replyToId
302
- ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}`
303
- : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`;
304
-
305
248
  const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
306
249
  expect(media).toHaveLength(expectedLength);
307
250
  };
@@ -321,25 +264,6 @@ const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpec
321
264
  expect(first?.placeholder).toBe(expected.placeholder);
322
265
  }
323
266
  };
324
- const expectMSTeamsMediaPayload = (
325
- payload: MSTeamsMediaPayload,
326
- expected: MSTeamsMediaPayloadExpectation,
327
- ) => {
328
- expect(payload.MediaPath).toBe(expected.firstPath);
329
- expect(payload.MediaUrl).toBe(expected.firstPath);
330
- expect(payload.MediaPaths).toEqual(expected.paths);
331
- expect(payload.MediaUrls).toEqual(expected.paths);
332
- expect(payload.MediaTypes).toEqual(expected.types);
333
- };
334
- type AttachmentPlaceholderCase = LabeledCase & {
335
- attachments: AttachmentPlaceholderInput;
336
- expected: string;
337
- };
338
- type CountedAttachmentPlaceholderCaseDef = LabeledCase & {
339
- attachments: AttachmentPlaceholderCase["attachments"];
340
- count: number;
341
- formatPlaceholder: (count: number) => string;
342
- };
343
267
  type AttachmentDownloadSuccessCase = LabeledCase & {
344
268
  attachments: MSTeamsAttachments;
345
269
  buildFetchFn?: () => unknown;
@@ -357,77 +281,6 @@ type AttachmentAuthRetryCase = LabeledCase & {
357
281
  expectedMediaLength: number;
358
282
  expectTokenFetch: boolean;
359
283
  };
360
- type GraphUrlExpectationCase = LabeledCase & {
361
- params: GraphMessageUrlParams;
362
- expectedPath: string;
363
- };
364
- type ChannelGraphUrlCaseParams = {
365
- messageId: string;
366
- replyToId?: string;
367
- conversationId?: string;
368
- };
369
- type GraphMediaDownloadResult = {
370
- fetchMock: ReturnType<typeof createGraphFetchMock>;
371
- media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>;
372
- };
373
- type GraphMediaSuccessCase = LabeledCase & {
374
- buildOptions: () => GraphFetchMockOptions;
375
- expectedLength: number;
376
- assert?: (params: GraphMediaDownloadResult) => void;
377
- };
378
- const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
379
- withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }),
380
- withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }),
381
- ];
382
- const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [
383
- withLabel("returns image placeholder for one image attachment", {
384
- attachments: createImageAttachments(TEST_URL_IMAGE_PNG),
385
- count: 1,
386
- formatPlaceholder: formatImagePlaceholder,
387
- }),
388
- withLabel("returns image placeholder with count for many image attachments", {
389
- attachments: [
390
- ...createImageAttachments(TEST_URL_IMAGE_1_PNG),
391
- { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG },
392
- ],
393
- count: 2,
394
- formatPlaceholder: formatImagePlaceholder,
395
- }),
396
- withLabel("treats Teams file.download.info image attachments as images", {
397
- attachments: createTeamsFileDownloadInfoAttachments(),
398
- count: 1,
399
- formatPlaceholder: formatImagePlaceholder,
400
- }),
401
- withLabel("returns document placeholder for non-image attachments", {
402
- attachments: createPdfAttachments(TEST_URL_PDF),
403
- count: 1,
404
- formatPlaceholder: formatDocumentPlaceholder,
405
- }),
406
- withLabel("returns document placeholder with count for many non-image attachments", {
407
- attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2),
408
- count: 2,
409
- formatPlaceholder: formatDocumentPlaceholder,
410
- }),
411
- withLabel("counts one inline image in html attachments", {
412
- attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"),
413
- count: 1,
414
- formatPlaceholder: formatImagePlaceholder,
415
- }),
416
- withLabel("counts many inline images in html attachments", {
417
- attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]),
418
- count: 2,
419
- formatPlaceholder: formatImagePlaceholder,
420
- }),
421
- ];
422
- const ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
423
- ...EMPTY_ATTACHMENT_PLACEHOLDER_CASES,
424
- ...COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS.map((testCase) =>
425
- withLabel(testCase.label, {
426
- attachments: testCase.attachments,
427
- expected: testCase.formatPlaceholder(testCase.count),
428
- }),
429
- ),
430
- ];
431
284
  const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
432
285
  withLabel("downloads and stores image contentUrl attachments", {
433
286
  attachments: asSingleItemArray(IMAGE_ATTACHMENT),
@@ -487,150 +340,6 @@ const ATTACHMENT_AUTH_RETRY_CASES: AttachmentAuthRetryCase[] = [
487
340
  expectTokenFetch: false,
488
341
  }),
489
342
  ];
490
- const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
491
- withLabel("downloads hostedContents images", {
492
- buildOptions: () => ({ hostedContents: createHostedImageContents("1") }),
493
- expectedLength: 1,
494
- assert: ({ fetchMock }) => {
495
- expect(fetchMock).toHaveBeenCalled();
496
- expectMediaBufferSaved();
497
- },
498
- }),
499
- withLabel("merges SharePoint reference attachments with hosted content", {
500
- buildOptions: () => {
501
- return {
502
- hostedContents: createHostedImageContents("hosted-1"),
503
- ...buildDefaultShareReferenceGraphFetchOptions({
504
- onShareRequest: () => createPdfResponse(),
505
- }),
506
- };
507
- },
508
- expectedLength: 2,
509
- }),
510
- ];
511
- const CHANNEL_GRAPH_URL_CASES: Array<LabeledCase & ChannelGraphUrlCaseParams> = [
512
- withLabel("builds channel message urls", {
513
- conversationId: "19:thread@thread.tacv2",
514
- messageId: "123",
515
- }),
516
- withLabel("builds channel reply urls when replyToId is present", {
517
- messageId: "reply-id",
518
- replyToId: "root-id",
519
- }),
520
- ];
521
- const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
522
- ...CHANNEL_GRAPH_URL_CASES.map<GraphUrlExpectationCase>(({ label, ...params }) =>
523
- withLabel(label, {
524
- params: createChannelGraphMessageUrlParams(params),
525
- expectedPath: buildExpectedChannelMessagePath(params),
526
- }),
527
- ),
528
- withLabel("builds chat message urls", {
529
- params: {
530
- conversationType: "groupChat" as const,
531
- conversationId: "19:chat@thread.v2",
532
- messageId: "456",
533
- },
534
- expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
535
- }),
536
- ];
537
-
538
- type GraphFetchMockOptions = {
539
- hostedContents?: unknown[];
540
- attachments?: unknown[];
541
- messageAttachments?: unknown[];
542
- onShareRequest?: (url: string) => Response | Promise<Response>;
543
- onUnhandled?: (url: string) => Response | Promise<Response> | undefined;
544
- };
545
-
546
- const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({
547
- id: "ref-1",
548
- contentType: "reference",
549
- contentUrl: shareUrl,
550
- name: "report.pdf",
551
- });
552
- const buildShareReferenceGraphFetchOptions = (params: {
553
- referenceAttachment: ReturnType<typeof createReferenceAttachment>;
554
- onShareRequest?: GraphFetchMockOptions["onShareRequest"];
555
- onUnhandled?: GraphFetchMockOptions["onUnhandled"];
556
- }) => ({
557
- attachments: [params.referenceAttachment],
558
- messageAttachments: [params.referenceAttachment],
559
- ...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}),
560
- ...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}),
561
- });
562
- const buildDefaultShareReferenceGraphFetchOptions = (
563
- params: Omit<Parameters<typeof buildShareReferenceGraphFetchOptions>[0], "referenceAttachment">,
564
- ) =>
565
- buildShareReferenceGraphFetchOptions({
566
- referenceAttachment: createReferenceAttachment(),
567
- ...params,
568
- });
569
- type GraphEndpointResponseHandler = {
570
- suffix: string;
571
- buildResponse: () => Response;
572
- };
573
- const createGraphEndpointResponseHandlers = (params: {
574
- hostedContents: unknown[];
575
- attachments: unknown[];
576
- messageAttachments: unknown[];
577
- }): GraphEndpointResponseHandler[] => [
578
- {
579
- suffix: "/hostedContents",
580
- buildResponse: () => createGraphCollectionResponse(params.hostedContents),
581
- },
582
- {
583
- suffix: "/attachments",
584
- buildResponse: () => createGraphCollectionResponse(params.attachments),
585
- },
586
- {
587
- suffix: "/messages/123",
588
- buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }),
589
- },
590
- ];
591
- const resolveGraphEndpointResponse = (
592
- url: string,
593
- handlers: GraphEndpointResponseHandler[],
594
- ): Response | undefined => {
595
- const handler = handlers.find((entry) => url.endsWith(entry.suffix));
596
- return handler ? handler.buildResponse() : undefined;
597
- };
598
-
599
- const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => {
600
- const hostedContents = options.hostedContents ?? [];
601
- const attachments = options.attachments ?? [];
602
- const messageAttachments = options.messageAttachments ?? [];
603
- const endpointHandlers = createGraphEndpointResponseHandlers({
604
- hostedContents,
605
- attachments,
606
- messageAttachments,
607
- });
608
- return vi.fn(async (url: string) => {
609
- const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers);
610
- if (endpointResponse) {
611
- return endpointResponse;
612
- }
613
- if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) {
614
- return options.onShareRequest(url);
615
- }
616
- const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined;
617
- return unhandled ?? createNotFoundResponse();
618
- });
619
- };
620
- const downloadGraphMediaWithMockOptions = async (
621
- options: GraphFetchMockOptions = {},
622
- overrides: DownloadGraphMediaOverrides = {},
623
- ): Promise<GraphMediaDownloadResult> => {
624
- const fetchMock = createGraphFetchMock(options);
625
- const media = await downloadMSTeamsGraphMedia({
626
- messageUrl: DEFAULT_MESSAGE_URL,
627
- tokenProvider: createTokenProvider(),
628
- maxBytes: DEFAULT_MAX_BYTES,
629
- fetchFn: asFetchFn(fetchMock),
630
- ...overrides,
631
- });
632
- return { fetchMock, media };
633
- };
634
343
  const runAttachmentDownloadSuccessCase = async ({
635
344
  attachments,
636
345
  buildFetchFn,
@@ -661,15 +370,6 @@ const runAttachmentAuthRetryCase = async ({
661
370
  expectAttachmentMediaLength(media, expectedMediaLength);
662
371
  expectMockCallState(tokenProvider.getAccessToken, expectTokenFetch);
663
372
  };
664
- const runGraphMediaSuccessCase = async ({
665
- buildOptions,
666
- expectedLength,
667
- assert,
668
- }: GraphMediaSuccessCase) => {
669
- const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions());
670
- expectAttachmentMediaLength(media.media, expectedLength);
671
- assert?.({ fetchMock, media });
672
- };
673
373
 
674
374
  describe("msteams attachments", () => {
675
375
  beforeEach(() => {
@@ -679,15 +379,6 @@ describe("msteams attachments", () => {
679
379
  setMSTeamsRuntime(runtimeStub);
680
380
  });
681
381
 
682
- describe("buildMSTeamsAttachmentPlaceholder", () => {
683
- it.each<AttachmentPlaceholderCase>(ATTACHMENT_PLACEHOLDER_CASES)(
684
- "$label",
685
- ({ attachments, expected }) => {
686
- expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
687
- },
688
- );
689
- });
690
-
691
382
  describe("downloadMSTeamsAttachments", () => {
692
383
  it.each<AttachmentDownloadSuccessCase>(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)(
693
384
  "$label",
@@ -740,7 +431,7 @@ describe("msteams attachments", () => {
740
431
 
741
432
  expectAttachmentMediaLength(media, 1);
742
433
  expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
743
- expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
434
+ expect(fetchMock.mock.calls.map(([calledUrl]) => calledUrl)).toContain(redirectedUrl);
744
435
  });
745
436
 
746
437
  it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
@@ -827,7 +518,7 @@ describe("msteams attachments", () => {
827
518
  it("blocks redirects to non-https URLs", async () => {
828
519
  const insecureUrl = "http://x/insecure.png";
829
520
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
830
- const url = typeof input === "string" ? input : input.toString();
521
+ const url = resolveRequestUrl(input);
831
522
  if (url === TEST_URL_IMAGE) {
832
523
  return createRedirectResponse(insecureUrl);
833
524
  }
@@ -848,94 +539,148 @@ describe("msteams attachments", () => {
848
539
  expectAttachmentMediaLength(media, 0);
849
540
  expect(fetchMock).toHaveBeenCalledTimes(1);
850
541
  });
851
- });
852
-
853
- describe("buildMSTeamsGraphMessageUrls", () => {
854
- it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
855
- const urls = buildMSTeamsGraphMessageUrls(params);
856
- expect(urls[0]).toContain(expectedPath);
857
- });
858
- });
859
-
860
- describe("downloadMSTeamsGraphMedia", () => {
861
- it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
862
-
863
- it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
864
- const tokenProvider = createTokenProvider("top-secret-token");
865
- const escapedUrl = "https://example.com/collect";
866
- const seen: Array<{ url: string; auth: string }> = [];
867
- const referenceAttachment = createReferenceAttachment();
868
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
869
- const url = String(input);
870
- const auth = new Headers(init?.headers).get("Authorization") ?? "";
871
- seen.push({ url, auth });
872
542
 
873
- if (url === DEFAULT_MESSAGE_URL) {
874
- return createJsonResponse({ attachments: [referenceAttachment] });
875
- }
876
- if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
877
- return createGraphCollectionResponse([]);
878
- }
879
- if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
880
- return createGraphCollectionResponse([referenceAttachment]);
881
- }
882
- if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
883
- return createRedirectResponse(escapedUrl);
884
- }
885
- if (url === escapedUrl) {
886
- return createPdfResponse();
543
+ describe("OneDrive/SharePoint shared links", () => {
544
+ const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
545
+ const DEFAULT_GRAPH_ALLOW_HOSTS = [GRAPH_HOST];
546
+ const PDF_PAYLOAD = Buffer.from("pdf-bytes");
547
+
548
+ const createGraphSharesFetchMock = () =>
549
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
550
+ const url = resolveRequestUrl(input);
551
+ const auth = new Headers(init?.headers).get("Authorization");
552
+ if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
553
+ if (!auth) {
554
+ return createTextResponse("unauthorized", 401);
555
+ }
556
+ return createBufferResponse(PDF_PAYLOAD, CONTENT_TYPE_APPLICATION_PDF);
557
+ }
558
+ return createNotFoundResponse();
559
+ });
560
+
561
+ it.each([
562
+ {
563
+ label: "SharePoint URL",
564
+ contentUrl: "https://contoso.sharepoint.com/personal/user/Documents/report.pdf",
565
+ },
566
+ {
567
+ label: "OneDrive 1drv.ms URL",
568
+ contentUrl: "https://1drv.ms/b/s!AkxYabcdefg",
569
+ },
570
+ {
571
+ label: "OneDrive onedrive.live.com URL",
572
+ contentUrl: "https://onedrive.live.com/share/file",
573
+ },
574
+ ])("routes $label through Graph shares endpoint", async ({ contentUrl }) => {
575
+ const tokenProvider = createTokenProvider();
576
+ const fetchMock = createGraphSharesFetchMock();
577
+ detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
578
+ saveMediaBufferMock.mockResolvedValueOnce({
579
+ id: "saved.pdf",
580
+ path: SAVED_PDF_PATH,
581
+ size: Buffer.byteLength(PDF_PAYLOAD),
582
+ contentType: CONTENT_TYPE_APPLICATION_PDF,
583
+ });
584
+
585
+ const media = await downloadMSTeamsAttachments(
586
+ buildDownloadParams(
587
+ [
588
+ {
589
+ contentType: "reference",
590
+ contentUrl,
591
+ name: "report.pdf",
592
+ },
593
+ ],
594
+ {
595
+ tokenProvider,
596
+ allowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
597
+ authAllowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
598
+ fetchFn: asFetchFn(fetchMock),
599
+ },
600
+ ),
601
+ );
602
+
603
+ expectAttachmentMediaLength(media, 1);
604
+ expect(media[0]?.path).toBe(SAVED_PDF_PATH);
605
+ // The only host that should be fetched is graph.microsoft.com.
606
+ const calledUrls = (fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>).map(
607
+ ([input]) => resolveRequestUrl(input),
608
+ );
609
+ expect(calledUrls.length).toBeGreaterThan(0);
610
+ for (const url of calledUrls) {
611
+ expect(url.startsWith(GRAPH_SHARES_URL_PREFIX)).toBe(true);
887
612
  }
888
- return createNotFoundResponse();
613
+ // Graph scope token was acquired for the shares fetch.
614
+ expect(tokenProvider.getAccessToken).toHaveBeenCalled();
889
615
  });
890
616
 
891
- const media = await downloadMSTeamsGraphMedia({
892
- messageUrl: DEFAULT_MESSAGE_URL,
893
- tokenProvider,
894
- maxBytes: DEFAULT_MAX_BYTES,
895
- allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
896
- authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
897
- fetchFn: asFetchFn(fetchMock),
617
+ it("falls through to direct fetch for non-shared-link URLs", async () => {
618
+ const directUrl = createTestUrl("direct.pdf");
619
+ const fetchMock = createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf");
620
+ detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
621
+ saveMediaBufferMock.mockResolvedValueOnce({
622
+ id: "saved.pdf",
623
+ path: SAVED_PDF_PATH,
624
+ size: Buffer.byteLength(PDF_BUFFER),
625
+ contentType: CONTENT_TYPE_APPLICATION_PDF,
626
+ });
627
+
628
+ const media = await downloadAttachmentsWithFetch(
629
+ createPdfAttachments(directUrl),
630
+ fetchMock,
631
+ );
632
+
633
+ expectAttachmentMediaLength(media, 1);
634
+ const calledUrls = (fetchMock.mock.calls as unknown[]).map((call) => {
635
+ const input = (call as [RequestInfo | URL])[0];
636
+ return resolveRequestUrl(input);
637
+ });
638
+ // Should have hit the original host, NOT graph shares.
639
+ expect(calledUrls.some((url) => url === directUrl)).toBe(true);
640
+ expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
898
641
  });
899
-
900
- expectAttachmentMediaLength(media.media, 1);
901
- const redirected = seen.find((entry) => entry.url === escapedUrl);
902
- expect(redirected).toBeDefined();
903
- expect(redirected?.auth).toBe("");
904
642
  });
905
643
 
906
- it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
907
- const escapedUrl = "https://evil.example/internal.pdf";
908
- const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
909
- {
910
- ...buildDefaultShareReferenceGraphFetchOptions({
911
- onShareRequest: () => createRedirectResponse(escapedUrl),
912
- onUnhandled: (url) => {
913
- if (url === escapedUrl) {
914
- return createPdfResponse("should-not-be-fetched");
915
- }
916
- return undefined;
917
- },
644
+ describe("error logging (issue #63396)", () => {
645
+ // Before this fix, fetch failures were swallowed by empty `catch {}`
646
+ // blocks, leaving operators with no signal that SharePoint downloads
647
+ // were silently failing on Node 24+. These tests pin the logger contract
648
+ // so the regression cannot return.
649
+ it("invokes logger.warn when a remote media download fails", async () => {
650
+ const logger = { warn: vi.fn(), error: vi.fn() };
651
+ const fetchMock = vi.fn(async () => createTextResponse("server error", 500));
652
+
653
+ const media = await downloadMSTeamsAttachments(
654
+ buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
655
+ fetchFn: asFetchFn(fetchMock),
656
+ logger,
918
657
  }),
919
- },
920
- {
921
- allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
922
- },
923
- );
658
+ );
659
+
660
+ expectAttachmentMediaLength(media, 0);
661
+ expect(logger.warn).toHaveBeenCalledWith(
662
+ "msteams attachment download failed",
663
+ expect.objectContaining({
664
+ error: expect.stringContaining("HTTP 500"),
665
+ host: expect.any(String),
666
+ }),
667
+ );
668
+ });
924
669
 
925
- expectAttachmentMediaLength(media.media, 0);
926
- const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
927
- expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true);
928
- expect(calledUrls).not.toContain(escapedUrl);
929
- });
930
- });
670
+ it("does not log when downloads succeed", async () => {
671
+ const logger = { warn: vi.fn(), error: vi.fn() };
672
+ const fetchMock = createOkFetchMock(CONTENT_TYPE_IMAGE_PNG);
673
+
674
+ const media = await downloadMSTeamsAttachments(
675
+ buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
676
+ fetchFn: asFetchFn(fetchMock),
677
+ logger,
678
+ }),
679
+ );
931
680
 
932
- describe("buildMSTeamsMediaPayload", () => {
933
- it("returns single and multi-file fields", async () => {
934
- const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png"));
935
- expectMSTeamsMediaPayload(payload, {
936
- firstPath: "/tmp/a.png",
937
- paths: ["/tmp/a.png", "/tmp/b.png"],
938
- types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG],
681
+ expectAttachmentMediaLength(media, 1);
682
+ expect(logger.warn).not.toHaveBeenCalled();
683
+ expect(logger.error).not.toHaveBeenCalled();
939
684
  });
940
685
  });
941
686
  });