1import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
5import { isVideoAttachment, videoMimeType } from "../../types";
6import {
7 attachmentsToReleaseOnUploadCapabilityLoss,
8 classifyComposerAttachmentFile,
9 composerOtherFilesForPresentation,
10 fileAttachmentCapabilityBlockReason,
11 fileAttachmentStagingLimit,
12 inferImageMimeTypeFromName,
13 isPreviewableComposerVideo,
14 normalizeComposerImageFileMimeType,
15 shouldHandleComposerAttachmentPaste,
16} from "./composerAttachmentFiles";
17
18describe("composer attachment files", () => {
19 it("keeps inline non-media files out of the legacy attachment row", () => {
20 const environmentId = EnvironmentId.make("env-1");
21 const files = [
22 {
23 type: "file" as const,
24 id: "inline-file",
25 name: "inline.txt",
26 mimeType: "text/plain",
27 sizeBytes: 12,
28 file: new File(["inline"], "inline.txt", { type: "text/plain" }),
29 },
30 {
31 type: "file" as const,
32 id: "legacy-file",
33 name: "legacy.zip",
34 mimeType: "application/zip",
35 sizeBytes: 24,
36 file: new File(["legacy"], "legacy.zip", { type: "application/zip" }),
37 },
38 ];
39
40 expect(
41 composerOtherFilesForPresentation(files, environmentId, new Set(["inline-file"])),
42 ).toEqual([files[1]]);
43 });
44
45 it("keeps supported images and HEIC photos on the image path", () => {
46 expect(classifyComposerAttachmentFile({ name: "photo.png", type: "image/png" })).toBe("image");
47 expect(classifyComposerAttachmentFile({ name: "photo.heic", type: "" })).toBe("image");
48 });
49
50 it("rejects unsupported image types instead of attaching them as generic files", () => {
51 expect(classifyComposerAttachmentFile({ name: "diagram.svg", type: "image/svg+xml" })).toBe(
52 "unsupported-image",
53 );
54 expect(classifyComposerAttachmentFile({ name: "photo.tiff", type: "image/tiff" })).toBe(
55 "unsupported-image",
56 );
57 expect(classifyComposerAttachmentFile({ name: "report.pdf", type: "application/pdf" })).toBe(
58 "file",
59 );
60 });
61
62 it("preserves text paste when an application adds a synthetic generic file", () => {
63 const file = new File(["clipboard"], "clipboard.rtf", { type: "application/rtf" });
64
65 expect(
66 shouldHandleComposerAttachmentPaste({
67 files: [file],
68 plainText: "Copied text",
69 }),
70 ).toBe(false);
71 });
72
73 it("claims unsupported image pastes so the composer can report them", () => {
74 const images = [
75 new File(["svg"], "diagram.svg", { type: "image/svg+xml" }),
76 new File(["tiff"], "photo.tiff", { type: "image/tiff" }),
77 ];
78
79 for (const image of images) {
80 expect(
81 shouldHandleComposerAttachmentPaste({
82 files: [image],
83 plainText: "Image caption",
84 }),
85 ).toBe(true);
86 }
87 });
88
89 it("claims generic file-only pastes so the composer can report validation errors", () => {
90 const file = new File(["report"], "report.pdf", { type: "application/pdf" });
91
92 expect(shouldHandleComposerAttachmentPaste({ files: [file], plainText: "" })).toBe(true);
93 });
94
95 it("routes empty and oversized generic files to composer feedback", () => {
96 const empty = new File([], "empty.txt", { type: "text/plain" });
97 const oversized = new File([new Uint8Array(1024)], "large.zip", {
98 type: "application/zip",
99 });
100
101 expect(shouldHandleComposerAttachmentPaste({ files: [empty], plainText: "" })).toBe(true);
102 expect(shouldHandleComposerAttachmentPaste({ files: [oversized], plainText: "" })).toBe(true);
103 });
104
105 it("ignores an empty clipboard", () => {
106 expect(shouldHandleComposerAttachmentPaste({ files: [], plainText: "" })).toBe(false);
107 });
108
109 it("falls back to the extension when an image arrives without a MIME type", () => {
110 expect(classifyComposerAttachmentFile({ name: "photo.jpg", type: "" })).toBe("image");
111 expect(classifyComposerAttachmentFile({ name: "shot.PNG", type: "" })).toBe("image");
112 expect(classifyComposerAttachmentFile({ name: "archive.zip", type: "" })).toBe("file");
113 expect(classifyComposerAttachmentFile({ name: "no-extension", type: "" })).toBe("file");
114 expect(inferImageMimeTypeFromName("photo.jpg")).toBe("image/jpeg");
115 expect(inferImageMimeTypeFromName("archive.zip")).toBeNull();
116 });
117
118 it("infers supported image types from octet-stream files", () => {
119 const jpeg = new File(["jpeg"], "photo.jpg", { type: "application/octet-stream" });
120 const png = new File(["png"], "shot.PNG", { type: "application/octet-stream" });
121
122 expect(classifyComposerAttachmentFile(jpeg)).toBe("image");
123 expect(classifyComposerAttachmentFile(png)).toBe("image");
124 expect(normalizeComposerImageFileMimeType(jpeg).type).toBe("image/jpeg");
125 expect(normalizeComposerImageFileMimeType(png).type).toBe("image/png");
126 });
127
128 it("does not infer images for unknown extensions or specific conflicting MIME types", () => {
129 const binary = new File(["binary"], "archive.bin", { type: "application/octet-stream" });
130 const unknownDocument = new File(["pdf"], "report.pdf", {
131 type: "application/octet-stream",
132 });
133 const document = new File(["pdf"], "photo.jpg", { type: "application/pdf" });
134 const explicitImage = new File(["png"], "photo.jpg", { type: "image/png" });
135
136 expect(classifyComposerAttachmentFile(binary)).toBe("file");
137 expect(classifyComposerAttachmentFile(unknownDocument)).toBe("file");
138 expect(classifyComposerAttachmentFile(document)).toBe("file");
139 expect(classifyComposerAttachmentFile(explicitImage)).toBe("image");
140 expect(normalizeComposerImageFileMimeType(binary)).toBe(binary);
141 expect(normalizeComposerImageFileMimeType(document)).toBe(document);
142 expect(normalizeComposerImageFileMimeType(explicitImage)).toBe(explicitImage);
143 });
144
145 it("uses the hard local limit while server config is unknown", () => {
146 expect(
147 fileAttachmentStagingLimit({
148 attachmentUploadsCapabilityKnown: false,
149 supportsAttachmentUploads: false,
150 maxFileAttachmentBytes: null,
151 }),
152 ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES);
153 expect(
154 fileAttachmentCapabilityBlockReason({
155 files: [{ name: "pending.zip", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }],
156 attachmentUploadsCapabilityKnown: false,
157 supportsAttachmentUploads: false,
158 maxFileAttachmentBytes: null,
159 }),
160 ).toBe("Waiting for the server before file attachments can send");
161 });
162
163 it("rejects local staging and send when known config has no file support", () => {
164 const unsupportedReason =
165 "This server does not accept file attachments right now. Remove the files to send.";
166 expect(
167 fileAttachmentStagingLimit({
168 attachmentUploadsCapabilityKnown: true,
169 supportsAttachmentUploads: true,
170 maxFileAttachmentBytes: null,
171 }),
172 ).toBeNull();
173 expect(
174 fileAttachmentStagingLimit({
175 attachmentUploadsCapabilityKnown: true,
176 supportsAttachmentUploads: false,
177 maxFileAttachmentBytes: 50 * 1024 * 1024,
178 }),
179 ).toBeNull();
180 expect(
181 fileAttachmentCapabilityBlockReason({
182 files: [{ name: "report.pdf", sizeBytes: 1024 }],
183 attachmentUploadsCapabilityKnown: true,
184 supportsAttachmentUploads: true,
185 maxFileAttachmentBytes: null,
186 }),
187 ).toBe(unsupportedReason);
188 expect(
189 fileAttachmentCapabilityBlockReason({
190 files: [{ name: "report.pdf", sizeBytes: 1024 }],
191 attachmentUploadsCapabilityKnown: true,
192 supportsAttachmentUploads: false,
193 maxFileAttachmentBytes: 50 * 1024 * 1024,
194 }),
195 ).toBe(unsupportedReason);
196 });
197
198 it("blocks retained files that exceed a newly lower server limit", () => {
199 expect(
200 fileAttachmentCapabilityBlockReason({
201 files: [{ name: "large.zip", sizeBytes: 2 * 1024 * 1024 }],
202 attachmentUploadsCapabilityKnown: true,
203 supportsAttachmentUploads: true,
204 maxFileAttachmentBytes: 1024 * 1024,
205 }),
206 ).toBe("'large.zip' exceeds the 1 MB attachment limit.");
207 });
208
209 it("uses the confirmed server limit without exceeding the hard cap", () => {
210 expect(
211 fileAttachmentStagingLimit({
212 attachmentUploadsCapabilityKnown: true,
213 supportsAttachmentUploads: true,
214 maxFileAttachmentBytes: 1024 * 1024,
215 }),
216 ).toBe(1024 * 1024);
217 expect(
218 fileAttachmentStagingLimit({
219 attachmentUploadsCapabilityKnown: true,
220 supportsAttachmentUploads: true,
221 maxFileAttachmentBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2,
222 }),
223 ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES);
224 expect(
225 fileAttachmentCapabilityBlockReason({
226 files: [{ name: "report.pdf", sizeBytes: 1024 }],
227 attachmentUploadsCapabilityKnown: true,
228 supportsAttachmentUploads: true,
229 maxFileAttachmentBytes: 50 * 1024 * 1024,
230 }),
231 ).toBeNull();
232 });
233
234 it("does not block empty or image-only composers on legacy servers", () => {
235 expect(
236 fileAttachmentCapabilityBlockReason({
237 files: [],
238 attachmentUploadsCapabilityKnown: true,
239 supportsAttachmentUploads: false,
240 maxFileAttachmentBytes: null,
241 }),
242 ).toBeNull();
243 expect(
244 fileAttachmentCapabilityBlockReason({
245 files: [],
246 attachmentUploadsCapabilityKnown: false,
247 supportsAttachmentUploads: false,
248 maxFileAttachmentBytes: null,
249 }),
250 ).toBeNull();
251 });
252
253 it("keeps draft-persisted file uploads when the upload capability flips off", () => {
254 const environmentId = EnvironmentId.make("environment-1");
255 const image: ComposerImageAttachment = {
256 type: "image",
257 id: "image-1",
258 name: "photo.png",
259 mimeType: "image/png",
260 sizeBytes: 3,
261 previewUrl: "blob:photo",
262 file: new File([new Uint8Array([1, 2, 3])], "photo.png", { type: "image/png" }),
263 };
264 const uploadingFile: ComposerFileAttachment = {
265 type: "file",
266 id: "file-uploading",
267 name: "fresh.pdf",
268 mimeType: "application/pdf",
269 sizeBytes: 3,
270 file: new File([new Uint8Array([1, 2, 3])], "fresh.pdf", { type: "application/pdf" }),
271 };
272 const hydratedFile: ComposerFileAttachment = {
273 type: "file",
274 id: "file-hydrated",
275 name: "report.pdf",
276 mimeType: "application/pdf",
277 sizeBytes: 3,
278 file: null,
279 uploadedAttachmentId: "pending-report-pdf",
280 uploadEnvironmentId: environmentId,
281 };
282 const uploadedLocalFile: ComposerFileAttachment = {
283 ...uploadingFile,
284 id: "file-uploaded-local",
285 uploadedAttachmentId: "pending-fresh-pdf",
286 uploadEnvironmentId: environmentId,
287 };
288
289 const released = attachmentsToReleaseOnUploadCapabilityLoss([
290 image,
291 uploadingFile,
292 hydratedFile,
293 uploadedLocalFile,
294 ]);
295
296 expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]);
297 });
298
299 it("keeps restored videos on the preview path in their upload environment", () => {
300 const environmentId = EnvironmentId.make("environment-1");
301 const video: ComposerFileAttachment = {
302 type: "file",
303 id: "video-1",
304 name: "clip.mp4",
305 mimeType: "video/mp4",
306 sizeBytes: 3,
307 file: null,
308 uploadedAttachmentId: "uploaded-video-1",
309 uploadEnvironmentId: environmentId,
310 };
311
312 expect(isPreviewableComposerVideo(video, environmentId)).toBe(true);
313 expect(isPreviewableComposerVideo(video, EnvironmentId.make("environment-2"))).toBe(false);
314 });
315
316 it("recognizes common video extensions when the browser omits the MIME type", () => {
317 const formats = [
318 ["clip.mp4", "video/mp4"],
319 ["clip.mov", "video/quicktime"],
320 ["clip.webm", "video/webm"],
321 ["clip.m4v", "video/mp4"],
322 ["clip.mkv", "video/x-matroska"],
323 ["clip.avi", "video/x-msvideo"],
324 ["clip.ogv", "video/ogg"],
325 ] as const;
326
327 for (const [name, expectedMimeType] of formats) {
328 expect(
329 isVideoAttachment({
330 type: "file",
331 id: name,
332 name,
333 mimeType: "application/octet-stream",
334 sizeBytes: 1,
335 }),
336 ).toBe(true);
337 expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBe(expectedMimeType);
338 }
339 });
340
341 it("claims image pastes even when clipboard text is present", () => {
342 const image = new File(["image"], "photo.heic", { type: "image/heic" });
343
344 expect(
345 shouldHandleComposerAttachmentPaste({
346 files: [image],
347 plainText: "Image caption",
348 }),
349 ).toBe(true);
350 });
351});