|
| 1 | +import { beforeEach, describe, expect, it } from "vitest"; |
| 2 | +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; |
| 3 | +import type { PluginRuntime } from "../runtime-api.js"; |
| 4 | +import { |
| 5 | + buildMSTeamsAttachmentPlaceholder, |
| 6 | + buildMSTeamsGraphMessageUrls, |
| 7 | + buildMSTeamsMediaPayload, |
| 8 | +} from "./attachments.js"; |
| 9 | +import { setMSTeamsRuntime } from "./runtime.js"; |
| 10 | + |
| 11 | +const GRAPH_HOST = "graph.microsoft.com"; |
| 12 | +const SHAREPOINT_HOST = "contoso.sharepoint.com"; |
| 13 | +const TEST_HOST = "x"; |
| 14 | +const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`; |
| 15 | +const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment); |
| 16 | +const TEST_URL_IMAGE = createTestUrl("img"); |
| 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"); |
| 25 | +const CONTENT_TYPE_IMAGE_PNG = "image/png"; |
| 26 | +const CONTENT_TYPE_APPLICATION_PDF = "application/pdf"; |
| 27 | +const CONTENT_TYPE_TEXT_HTML = "text/html"; |
| 28 | +const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info"; |
| 29 | +type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0]; |
| 30 | +type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0]; |
| 31 | +type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>; |
| 32 | + |
| 33 | +const runtimeStub: PluginRuntime = createPluginRuntimeMock(); |
| 34 | +const MEDIA_PLACEHOLDER_IMAGE = "<media:image>"; |
| 35 | +const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>"; |
| 36 | +const formatImagePlaceholder = (count: number) => |
| 37 | + count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE; |
| 38 | +const formatDocumentPlaceholder = (count: number) => |
| 39 | + count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT; |
| 40 | +const withLabel = <T extends object>(label: string, fields: T): T & { label: string } => ({ |
| 41 | + label, |
| 42 | + ...fields, |
| 43 | +}); |
| 44 | +const buildAttachment = <T extends Record<string, unknown>>(contentType: string, props: T) => ({ |
| 45 | + contentType, |
| 46 | + ...props, |
| 47 | +}); |
| 48 | +const createHtmlAttachment = (content: string) => |
| 49 | + buildAttachment(CONTENT_TYPE_TEXT_HTML, { content }); |
| 50 | +const buildHtmlImageTag = (src: string) => `<img src="${src}" />`; |
| 51 | +const createHtmlImageAttachments = (sources: string[], prefix = "") => [ |
| 52 | + createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`), |
| 53 | +]; |
| 54 | +const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) => |
| 55 | + contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl })); |
| 56 | +const createImageAttachments = (...contentUrls: string[]) => |
| 57 | + createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls); |
| 58 | +const createPdfAttachments = (...contentUrls: string[]) => |
| 59 | + createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls); |
| 60 | +const createTeamsFileDownloadInfoAttachments = ( |
| 61 | + downloadUrl = createTestUrl("dl"), |
| 62 | + fileType = "png", |
| 63 | +) => [ |
| 64 | + buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, { |
| 65 | + content: { downloadUrl, fileType }, |
| 66 | + }), |
| 67 | +]; |
| 68 | +const createMediaEntriesWithType = (contentType: string, ...paths: string[]) => |
| 69 | + paths.map((path) => ({ path, contentType })); |
| 70 | +const createImageMediaEntries = (...paths: string[]) => |
| 71 | + createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths); |
| 72 | +const DEFAULT_CHANNEL_TEAM_ID = "team-id"; |
| 73 | +const DEFAULT_CHANNEL_ID = "chan-id"; |
| 74 | +const createChannelGraphMessageUrlParams = (params: { |
| 75 | + messageId: string; |
| 76 | + replyToId?: string; |
| 77 | + conversationId?: string; |
| 78 | +}) => ({ |
| 79 | + conversationType: "channel" as const, |
| 80 | + ...params, |
| 81 | + channelData: { |
| 82 | + team: { id: DEFAULT_CHANNEL_TEAM_ID }, |
| 83 | + channel: { id: DEFAULT_CHANNEL_ID }, |
| 84 | + }, |
| 85 | +}); |
| 86 | +const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) => |
| 87 | + params.replyToId |
| 88 | + ? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}` |
| 89 | + : `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`; |
| 90 | + |
| 91 | +const expectMSTeamsMediaPayload = ( |
| 92 | + payload: MSTeamsMediaPayload, |
| 93 | + expected: { firstPath: string; paths: string[]; types: string[] }, |
| 94 | +) => { |
| 95 | + expect(payload.MediaPath).toBe(expected.firstPath); |
| 96 | + expect(payload.MediaUrl).toBe(expected.firstPath); |
| 97 | + expect(payload.MediaPaths).toEqual(expected.paths); |
| 98 | + expect(payload.MediaUrls).toEqual(expected.paths); |
| 99 | + expect(payload.MediaTypes).toEqual(expected.types); |
| 100 | +}; |
| 101 | + |
| 102 | +const ATTACHMENT_PLACEHOLDER_CASES = [ |
| 103 | + withLabel("returns empty string when no attachments", { |
| 104 | + attachments: undefined as AttachmentPlaceholderInput, |
| 105 | + expected: "", |
| 106 | + }), |
| 107 | + withLabel("returns empty string when attachments are empty", { |
| 108 | + attachments: [], |
| 109 | + expected: "", |
| 110 | + }), |
| 111 | + withLabel("returns image placeholder for one image attachment", { |
| 112 | + attachments: createImageAttachments(TEST_URL_IMAGE_PNG), |
| 113 | + expected: formatImagePlaceholder(1), |
| 114 | + }), |
| 115 | + withLabel("returns image placeholder with count for many image attachments", { |
| 116 | + attachments: [ |
| 117 | + ...createImageAttachments(TEST_URL_IMAGE_1_PNG), |
| 118 | + { contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG }, |
| 119 | + ], |
| 120 | + expected: formatImagePlaceholder(2), |
| 121 | + }), |
| 122 | + withLabel("treats Teams file.download.info image attachments as images", { |
| 123 | + attachments: createTeamsFileDownloadInfoAttachments(), |
| 124 | + expected: formatImagePlaceholder(1), |
| 125 | + }), |
| 126 | + withLabel("returns document placeholder for non-image attachments", { |
| 127 | + attachments: createPdfAttachments(TEST_URL_PDF), |
| 128 | + expected: formatDocumentPlaceholder(1), |
| 129 | + }), |
| 130 | + withLabel("returns document placeholder with count for many non-image attachments", { |
| 131 | + attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2), |
| 132 | + expected: formatDocumentPlaceholder(2), |
| 133 | + }), |
| 134 | + withLabel("counts one inline image in html attachments", { |
| 135 | + attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"), |
| 136 | + expected: formatImagePlaceholder(1), |
| 137 | + }), |
| 138 | + withLabel("counts many inline images in html attachments", { |
| 139 | + attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]), |
| 140 | + expected: formatImagePlaceholder(2), |
| 141 | + }), |
| 142 | +]; |
| 143 | + |
| 144 | +const GRAPH_URL_EXPECTATION_CASES = [ |
| 145 | + withLabel("builds channel message urls", { |
| 146 | + params: createChannelGraphMessageUrlParams({ |
| 147 | + conversationId: "19:thread@thread.tacv2", |
| 148 | + messageId: "123", |
| 149 | + }), |
| 150 | + expectedPath: buildExpectedChannelMessagePath({ messageId: "123" }), |
| 151 | + }), |
| 152 | + withLabel("builds channel reply urls when replyToId is present", { |
| 153 | + params: createChannelGraphMessageUrlParams({ |
| 154 | + messageId: "reply-id", |
| 155 | + replyToId: "root-id", |
| 156 | + }), |
| 157 | + expectedPath: buildExpectedChannelMessagePath({ |
| 158 | + messageId: "reply-id", |
| 159 | + replyToId: "root-id", |
| 160 | + }), |
| 161 | + }), |
| 162 | + withLabel("builds chat message urls", { |
| 163 | + params: { |
| 164 | + conversationType: "groupChat" as const, |
| 165 | + conversationId: "19:chat@thread.v2", |
| 166 | + messageId: "456", |
| 167 | + } satisfies GraphMessageUrlParams, |
| 168 | + expectedPath: "/chats/19%3Achat%40thread.v2/messages/456", |
| 169 | + }), |
| 170 | +]; |
| 171 | + |
| 172 | +describe("msteams attachment helpers", () => { |
| 173 | + beforeEach(() => { |
| 174 | + setMSTeamsRuntime(runtimeStub); |
| 175 | + }); |
| 176 | + |
| 177 | + describe("buildMSTeamsAttachmentPlaceholder", () => { |
| 178 | + it.each(ATTACHMENT_PLACEHOLDER_CASES)("$label", ({ attachments, expected }) => { |
| 179 | + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); |
| 180 | + }); |
| 181 | + }); |
| 182 | + |
| 183 | + describe("buildMSTeamsGraphMessageUrls", () => { |
| 184 | + it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => { |
| 185 | + const urls = buildMSTeamsGraphMessageUrls(params); |
| 186 | + expect(urls[0]).toContain(expectedPath); |
| 187 | + }); |
| 188 | + }); |
| 189 | + |
| 190 | + describe("buildMSTeamsMediaPayload", () => { |
| 191 | + it("returns single and multi-file fields", () => { |
| 192 | + const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png")); |
| 193 | + expectMSTeamsMediaPayload(payload, { |
| 194 | + firstPath: "/tmp/a.png", |
| 195 | + paths: ["/tmp/a.png", "/tmp/b.png"], |
| 196 | + types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG], |
| 197 | + }); |
| 198 | + }); |
| 199 | + }); |
| 200 | + |
| 201 | + it("retains the expected sharepoint host fixture", () => { |
| 202 | + expect(SHAREPOINT_HOST).toBe("contoso.sharepoint.com"); |
| 203 | + expect(TEST_URL_IMAGE).toContain(TEST_HOST); |
| 204 | + }); |
| 205 | +}); |
0 commit comments