Skip to content
Source
Sign inSign up

composerFiles.test.ts

1,008 lines34.7 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
3import type { ImagePickerAsset } from "expo-image-picker";
4​
5const mocks = vi.hoisted(() => ({
6 documentUri: "file:///documents",
7 pickFile: vi.fn(),
8 pickMedia: vi.fn(),
9 copy: vi.fn(),
10 delete: vi.fn(),
11 open: vi.fn(),
12 size: vi.fn(),
13 readBase64: vi.fn(),
14 manipulate: vi.fn(),
15 release: vi.fn(),
16}));
17​
18vi.mock("expo-file-system", () => {
19 class Directory {
20 readonly uri: string;
21​
22 constructor(root: string | { readonly uri: string }, name: string) {
23 this.uri = `${typeof root === "string" ? root : root.uri}/${name}`;
24 }
25​
26 create(): void {}
27 }
28​
29 class File {
30 readonly uri: string;
31​
32 constructor(source: string | Directory, name?: string) {
33 this.uri = source instanceof Directory ? `${source.uri}/${name}` : source;
34 }
35​
36 get exists(): boolean {
37 return true;
38 }
39​
40 get size(): number | null {
41 return mocks.size(this.uri) ?? null;
42 }
43​
44 get name(): string {
45 return this.uri.split("/").at(-1) ?? "";
46 }
47​
48 get type(): string {
49 return "video/quicktime";
50 }
51​
52 create(): void {}
53​
54 open(mode: string) {
55 return mocks.open(this.uri, mode);
56 }
57​
58 async copy(destination: File): Promise<void> {
59 mocks.copy(this.uri, destination.uri);
60 }
61​
62 async base64(): Promise<string> {
63 return mocks.readBase64(this.uri);
64 }
65​
66 delete(): void {
67 mocks.delete(this.uri);
68 }
69 }
70​
71 return {
72 Directory,
73 File,
74 FileMode: { ReadOnly: "r", WriteOnly: "w" },
75 Paths: {
76 get document() {
77 return { uri: mocks.documentUri };
78 },
79 },
80 };
81});
82​
83vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));
84vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile }));
85vi.mock("expo-image-manipulator", () => ({
86 SaveFormat: { JPEG: "jpeg", PNG: "png", WEBP: "webp" },
87 ImageManipulator: { manipulate: mocks.manipulate },
88}));
89vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" }));
90​
91import {
92 persistComposerAttachmentFile,
93 isComposerImageAttachment,
94 pickComposerFiles,
95 pickComposerImages,
96 pickComposerMedia,
97 removePersistedComposerAttachmentFile,
98} from "./composerImages";
99import { isForegroundHandoffActive } from "./foreground-handoff";
100import { retainComposerAttachmentFile } from "./composerAttachmentFiles";
101​
102describe("composer file attachments", () => {
103 beforeEach(() => {
104 mocks.documentUri = "file:///documents";
105 mocks.pickFile.mockReset();
106 mocks.pickMedia.mockReset();
107 mocks.copy.mockReset();
108 mocks.delete.mockReset();
109 mocks.open.mockReset();
110 mocks.size.mockReset();
111 mocks.readBase64.mockReset();
112 mocks.manipulate.mockReset();
113 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
114 });
115​
116 describe("photo library image conversion", () => {
117 const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" };
118 const photo: ImagePickerAsset = {
119 uri: "file:///picker/photo.heic",
120 type: "image",
121 fileName: "photo.HEIC",
122 mimeType: "image/heic",
123 fileSize: 4 * 1024 * 1024,
124 width: 4032,
125 height: 3024,
126 };
127 type Size = { width: number; height: number };
128 const native = {
129 size: { width: 1, height: 1 } as Size,
130 resizes: [] as Size[],
131 saved: rendered as { uri: string; base64?: string },
132 encode: null as ((size: Size, format: string) => { uri: string; base64?: string }) | null,
133 };
134​
135 beforeEach(() => {
136 native.size = { width: 1, height: 1 };
137 native.resizes = [];
138 native.saved = rendered;
139 native.encode = null;
140 mocks.manipulate.mockReset();
141 mocks.release.mockReset();
142 mocks.manipulate.mockImplementation((source: string | Size) => {
143 let size = typeof source === "string" ? native.size : source;
144 const context = {
145 resize(next: Size) {
146 native.resizes.push(next);
147 size = next;
148 return context;
149 },
150 renderAsync: async () => ({
151 width: size.width,
152 height: size.height,
153 release: mocks.release,
154 saveAsync: async (options: { format: string }) =>
155 native.encode?.(size, options.format) ?? native.saved,
156 }),
157 };
158 return context;
159 });
160 });
161​
162 it.each(["image/heic", "image/heif", undefined])(
163 "renders a %s photo to JPEG natively and previews the rendered file",
164 async (mimeType) => {
165 mocks.pickMedia.mockResolvedValue({
166 canceled: false,
167 assets: [{ ...photo, mimeType }],
168 });
169​
170 const result = await pickComposerImages({ existingCount: 0 });
171​
172 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false }));
173 expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
174 expect(mocks.readBase64).not.toHaveBeenCalled();
175 expect(result).toEqual({
176 images: [
177 {
178 id: "attachment-id",
179 type: "image",
180 name: "photo.jpg",
181 mimeType: "image/jpeg",
182 sizeBytes: 4,
183 dataUrl: `data:image/jpeg;base64,${rendered.base64}`,
184 previewUri: rendered.uri,
185 },
186 ],
187 error: null,
188 });
189 },
190 );
191​
192 it.each([
193 { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },
194 { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },
195 { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },
196 ])("keeps original $extension bytes from the picker file", async (original) => {
197 const name = `photo.${original.extension}`;
198 mocks.pickMedia.mockResolvedValue({
199 canceled: false,
200 assets: [{ ...photo, fileName: name, mimeType: original.mimeType }],
201 });
202 mocks.readBase64.mockResolvedValue(original.base64);
203​
204 const result = await pickComposerImages({ existingCount: 0 });
205​
206 expect(mocks.manipulate).not.toHaveBeenCalled();
207 expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri);
208 expect(result.error).toBeNull();
209 expect(result.images).toEqual([
210 expect.objectContaining({
211 name,
212 mimeType: original.mimeType,
213 dataUrl: `data:${original.mimeType};base64,${original.base64}`,
214 sizeBytes: Buffer.from(original.base64, "base64").byteLength,
215 previewUri: photo.uri,
216 }),
217 ]);
218 });
219​
220 it("shrinks a fitted PNG further when re-encoding it exceeds the image limit", async () => {
221 const header = Buffer.alloc(24);
222 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header);
223 header.write("IHDR", 12);
224 header.writeUInt32BE(1668, 16);
225 header.writeUInt32BE(2388, 20);
226 native.size = { width: 1668, height: 2388 };
227 // Native PNG encoding of the 2000px resample is larger than the 7 MB original.
228 native.encode = (size, format) => ({
229 uri: `file:///cache/fitted-${size.width}x${size.height}.${format}`,
230 base64: Buffer.alloc(size.width * size.height * 4).toString("base64"),
231 });
232 mocks.size.mockReturnValue(7 * 1024 * 1024);
233 mocks.readBase64.mockResolvedValue(header.toString("base64"));
234 mocks.pickMedia.mockResolvedValue({
235 canceled: false,
236 assets: [{ ...photo, fileName: "IMG_9280.png", mimeType: "image/png" }],
237 });
238​
239 const result = await pickComposerImages({ existingCount: 0 });
240​
241 expect(result.error).toBeNull();
242 const [image] = result.images;
243 const last = native.resizes.at(-1)!;
244 expect(native.resizes[0]).toEqual({ width: 1397, height: 2000 });
245 expect(Math.max(last.width, last.height)).toBeLessThan(2000);
246 expect(image).toEqual(
247 expect.objectContaining({
248 name: "IMG_9280.png",
249 mimeType: "image/png",
250 sizeBytes: last.width * last.height * 4,
251 previewUri: `file:///cache/fitted-${last.width}x${last.height}.png`,
252 }),
253 );
254 expect(image!.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
255 expect(mocks.delete).toHaveBeenCalledWith("file:///cache/fitted-1397x2000.png");
256 expect(mocks.delete).not.toHaveBeenCalledWith(image!.previewUri);
257 });
258​
259 it("measures the picker file instead of trusting the reported size", async () => {
260 mocks.pickMedia.mockResolvedValue({
261 canceled: false,
262 assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }],
263 });
264 mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);
265​
266 const result = await pickComposerImages({ existingCount: 0 });
267​
268 expect(mocks.readBase64).not.toHaveBeenCalled();
269 expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
270 expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
271 });
272​
273 // regression: #11440 render photo library picks to a bounded JPEG off the JS thread
274 it("renders a supported original whose size cannot be measured", async () => {
275 mocks.pickMedia.mockResolvedValue({
276 canceled: false,
277 assets: [
278 { ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" },
279 ],
280 });
281​
282 const result = await pickComposerImages({ existingCount: 0 });
283​
284 expect(mocks.readBase64).not.toHaveBeenCalled();
285 expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1");
286 expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
287 });
288​
289 it("checks the rendered JPEG against the image limit", async () => {
290 native.saved = {
291 uri: rendered.uri,
292 base64:
293 rendered.base64.slice(0, 4) +
294 "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4),
295 };
296 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });
297​
298 await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
299 images: [],
300 error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
301 });
302 });
303​
304 it("retains a rendered photo when another original cannot be read", async () => {
305 mocks.pickMedia.mockResolvedValue({
306 canceled: false,
307 assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],
308 });
309 mocks.readBase64.mockRejectedValue(new Error("missing file"));
310​
311 const result = await pickComposerImages({ existingCount: 0 });
312​
313 expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);
314 expect(result.error).toBe("Failed to read 'missing.gif'.");
315 });
316​
317 // regression: #11440 render photo library picks to a bounded JPEG off the JS thread
318 it("reports a photo the native renderer cannot decode", async () => {
319 mocks.manipulate.mockImplementation(() => ({
320 resize: () => {
321 throw new Error("unreachable");
322 },
323 renderAsync: () => Promise.reject(new Error("corrupt")),
324 }));
325 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });
326​
327 await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
328 images: [],
329 error: "Failed to read 'photo.HEIC'.",
330 });
331 });
332 });
333​
334 describe("photo library videos", () => {
335 const image: ImagePickerAsset = {
336 uri: "file:///picker/photo.png",
337 type: "image",
338 fileName: "photo.png",
339 mimeType: "image/png",
340 fileSize: 3,
341 width: 1,
342 height: 1,
343 };
344​
345 beforeEach(() => {
346 mocks.readBase64.mockResolvedValue("YWJj");
347 });
348 const video: ImagePickerAsset = {
349 uri: "file:///picker/clip.mov",
350 type: "video",
351 fileName: "clip.mov",
352 mimeType: "video/quicktime",
353 fileSize: 20 * 1024 * 1024,
354 base64: null,
355 width: 1920,
356 height: 1080,
357 };
358​
359 it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => {
360 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
361 mocks.size.mockImplementation((uri: string) =>
362 uri.endsWith("clip.mov") ? video.fileSize : 3,
363 );
364​
365 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });
366​
367 expect(mocks.pickMedia).toHaveBeenCalledWith(
368 expect.objectContaining({
369 mediaTypes: ["images", "videos"],
370 shouldDownloadFromNetwork: true,
371 }),
372 );
373 expect(result).toEqual({
374 attachments: [
375 expect.objectContaining({ type: "image", dataUrl: "data:image/png;base64,YWJj" }),
376 {
377 id: "attachment-id",
378 type: "file",
379 name: "clip.mov",
380 mimeType: "video/quicktime",
381 sizeBytes: video.fileSize,
382 fileUri: "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
383 },
384 ],
385 error: null,
386 });
387 expect(mocks.copy).toHaveBeenCalledWith(
388 video.uri,
389 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
390 );
391 expect(mocks.delete).not.toHaveBeenCalled();
392 });
393​
394 it("does not persist videos when the destination lacks file support", async () => {
395 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] });
396​
397 const result = await pickComposerMedia({ existingCount: 0 });
398​
399 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
400 expect(result.error).toBe("Video attachments are unavailable here.");
401 expect(mocks.copy).not.toHaveBeenCalled();
402 });
403​
404 it("uses local video metadata when the picker omits its name, MIME type, or size", async () => {
405 mocks.pickMedia.mockResolvedValue({
406 canceled: false,
407 assets: [{ ...video, fileName: null, mimeType: undefined, fileSize: undefined }],
408 });
409​
410 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 });
411​
412 expect(result.error).toBeNull();
413 expect(result.attachments).toEqual([
414 expect.objectContaining({
415 type: "file",
416 name: "clip.mov",
417 mimeType: "video/quicktime",
418 sizeBytes: 42,
419 }),
420 ]);
421 });
422​
423 it.each([
424 {
425 reason: "actual size exceeds the server limit",
426 reported: 42,
427 stored: 2 * 1024 * 1024,
428 limit: 1024 * 1024,
429 error: "'clip.mov' exceeds the 1 MB attachment limit.",
430 },
431 {
432 reason: "stored copy is empty",
433 reported: 42,
434 stored: 0,
435 limit: 1024 * 1024,
436 error: "'clip.mov' is empty or could not be read.",
437 },
438 {
439 reason: "server advertises more than the contract limit",
440 reported: 51 * 1024 * 1024,
441 stored: 42,
442 limit: 80 * 1024 * 1024,
443 error: "'clip.mov' exceeds the 50 MB attachment limit.",
444 },
445 ])(
446 "rejects a video when $reason while retaining the selected photo",
447 async ({ reported, stored, limit, error }) => {
448 mocks.pickMedia.mockResolvedValue({
449 canceled: false,
450 assets: [{ ...video, fileSize: reported }, image],
451 });
452 mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3));
453​
454 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit });
455​
456 expect(result).toEqual({
457 attachments: [expect.objectContaining({ type: "image" })],
458 error,
459 });
460 if (stored === 0) {
461 expect(mocks.delete).toHaveBeenCalledWith(
462 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
463 );
464 }
465 },
466 );
467​
468 it("applies the remaining attachment slots to photos and videos together", async () => {
469 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
470​
471 const result = await pickComposerMedia({
472 existingCount: 99,
473 maxVideoBytes: 50 * 1024 * 1024,
474 });
475​
476 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
477 expect(result.error).toBe("You can attach up to 100 attachments per message.");
478 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
479 expect(mocks.copy).not.toHaveBeenCalled();
480 });
481​
482 it("reports a native video retrieval error and ends the foreground handoff", async () => {
483 mocks.pickMedia.mockRejectedValue(new Error("Could not download video from iCloud."));
484​
485 await expect(pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 })).resolves.toEqual({
486 attachments: [],
487 error: "Could not download video from iCloud.",
488 });
489 expect(isForegroundHandoffActive()).toBe(false);
490 });
491 });
492​
493 describe("images chosen through the file picker", () => {
494 it.each([
495 { name: "photo.bin", mimeType: "image/jpeg", expected: "image/jpeg" },
496 { name: "photo.png", mimeType: "image/png", expected: "image/png" },
497 { name: "photo.gif", mimeType: "image/gif", expected: "image/gif" },
498 { name: "photo.webp", mimeType: "image/webp", expected: "image/webp" },
499 { name: "photo.JPG", mimeType: undefined, expected: "image/jpeg" },
500 { name: "photo.PNG", mimeType: "application/octet-stream", expected: "image/png" },
501 { name: "photo.gif", mimeType: "binary/octet-stream", expected: "image/gif" },
502 { name: "photo.webp", mimeType: "application/unknown", expected: "image/webp" },
503 { name: "photo.jpg", mimeType: "IMAGE/PNG; charset=binary", expected: "image/png" },
504 ])("prepares $name ($mimeType) as $expected", async ({ name, mimeType, expected }) => {
505 mocks.pickFile.mockResolvedValue({
506 canceled: false,
507 assets: [{ uri: `file:///picker/${name}`, name, mimeType, size: 3 }],
508 });
509 mocks.size.mockReturnValue(3);
510 mocks.readBase64.mockResolvedValue("YWJj");
511​
512 const result = await pickComposerFiles({ existingCount: 0 });
513​
514 expect(result).toEqual({
515 files: [
516 {
517 id: "attachment-id",
518 type: "image",
519 name,
520 mimeType: expected,
521 sizeBytes: 3,
522 dataUrl: `data:${expected};base64,YWJj`,
523 previewUri: `data:${expected};base64,YWJj`,
524 },
525 ],
526 error: null,
527 });
528 expect(mocks.manipulate).not.toHaveBeenCalled();
529 expect(mocks.delete).toHaveBeenCalledWith(
530 `file:///documents/t3-composer-attachments/attachment-id-${name}`,
531 );
532 });
533​
534 it.each([
535 { name: "diagram.svg", mimeType: "image/svg+xml" },
536 { name: "photo.heic", mimeType: "image/heic" },
537 { name: "photo.tiff", mimeType: "image/tiff" },
538 { name: "photo.png", mimeType: "application/pdf" },
539 { name: "unknown.bin", mimeType: "application/octet-stream" },
540 ])("keeps $name ($mimeType) as a file", async ({ name, mimeType }) => {
541 mocks.pickFile.mockResolvedValue({
542 canceled: false,
543 assets: [{ uri: `file:///picker/${name}`, name, mimeType, size: 42 }],
544 });
545​
546 const result = await pickComposerFiles({ existingCount: 0 });
547​
548 expect(result.error).toBeNull();
549 expect(result.files).toEqual([expect.objectContaining({ type: "file", name, mimeType })]);
550 expect(mocks.readBase64).not.toHaveBeenCalled();
551 expect(mocks.manipulate).not.toHaveBeenCalled();
552 expect(mocks.delete).not.toHaveBeenCalled();
553 });
554​
555 it.each([PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1])(
556 "classifies a measured %s byte PNG independently of picker metadata and file limit",
557 async (size) => {
558 mocks.pickFile.mockResolvedValue({
559 canceled: false,
560 assets: [
561 { uri: "file:///picker/photo.png", name: "photo.png", mimeType: "image/png", size: 1 },
562 ],
563 });
564 mocks.size.mockReturnValue(size);
565 mocks.readBase64.mockResolvedValue("YWJj");
566​
567 const result = await pickComposerFiles({
568 existingCount: 0,
569 maxBytes: size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES ? size : 1024,
570 });
571​
572 expect(result.error).toBeNull();
573 expect(result.files[0]?.type).toBe(
574 size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES ? "file" : "image",
575 );
576 expect(isComposerImageAttachment(result.files[0]!)).toBe(
577 size <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
578 );
579 expect(mocks.readBase64).toHaveBeenCalledTimes(
580 size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES ? 0 : 1,
581 );
582 },
583 );
584​
585 it.each([false, true])("fits a large PNG with oversized output: %s", async (oversized) => {
586 const header = Buffer.alloc(24);
587 Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header);
588 header.write("IHDR", 12);
589 header.writeUInt32BE(4000, 16);
590 header.writeUInt32BE(3000, 20);
591 const original = header.toString("base64");
592 mocks.readBase64.mockResolvedValue(original);
593 const resize = vi.fn();
594 const renderAsync = vi.fn(async () => ({
595 width: 4000,
596 height: 3000,
597 release: vi.fn(),
598 saveAsync: vi.fn(async () => ({
599 uri: "file:///cache/fitted.png",
600 base64: oversized
601 ? Buffer.alloc(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1).toString("base64")
602 : "YWJj",
603 })),
604 }));
605 mocks.manipulate.mockImplementation(() => {
606 const context = {
607 resize: (size: unknown) => {
608 resize(size);
609 return context;
610 },
611 renderAsync,
612 };
613 return context;
614 });
615 mocks.pickFile.mockResolvedValue({
616 canceled: false,
617 assets: [{ uri: "file:///picker/photo.png", name: "photo.png", mimeType: "image/png" }],
618 });
619​
620 const result = await pickComposerFiles({ existingCount: 0 });
621​
622 expect(resize).toHaveBeenCalledWith({ width: 2000, height: 1500 });
623 expect(result).toEqual({
624 files: [
625 expect.objectContaining({
626 type: "image",
627 mimeType: "image/png",
628 sizeBytes: oversized ? header.length : 3,
629 dataUrl: `data:image/png;base64,${oversized ? original : "YWJj"}`,
630 previewUri: oversized
631 ? `data:image/png;base64,${original}`
632 : "file:///cache/fitted.png",
633 }),
634 ],
635 error: null,
636 });
637 });
638​
639 it("releases the owned copy when reading an image fails", async () => {
640 mocks.pickFile.mockResolvedValue({
641 canceled: false,
642 assets: [{ uri: "file:///picker/photo.png", name: "photo.png", mimeType: "image/png" }],
643 });
644 mocks.readBase64.mockRejectedValue(new Error("unreadable image"));
645​
646 expect(await pickComposerFiles({ existingCount: 0 })).toEqual({
647 files: [],
648 error: "unreadable image",
649 });
650 expect(mocks.delete).toHaveBeenCalledWith(
651 "file:///documents/t3-composer-attachments/attachment-id-photo.png",
652 );
653 });
654 });
655​
656 it("copies picked files into app-owned storage without loading their contents", async () => {
657 mocks.pickFile.mockResolvedValue({
658 canceled: false,
659 assets: [
660 {
661 uri: "file:///downloads/report.pdf",
662 name: "report.pdf",
663 mimeType: "application/pdf",
664 size: 42,
665 },
666 ],
667 });
668​
669 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
670 files: [
671 {
672 id: "attachment-id",
673 type: "file",
674 name: "report.pdf",
675 mimeType: "application/pdf",
676 sizeBytes: 42,
677 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
678 },
679 ],
680 error: null,
681 });
682 expect(mocks.copy).toHaveBeenCalledWith(
683 "file:///downloads/report.pdf",
684 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
685 );
686 });
687​
688 it("preserves Android picker metadata instead of using the content URI document id", async () => {
689 const uri = "content://com.android.providers.media.documents/document/video%3A18";
690 mocks.pickFile.mockResolvedValue({
691 canceled: false,
692 assets: [
693 {
694 uri,
695 name: "preview-h264.mp4",
696 mimeType: "video/mp4",
697 size: 620_992,
698 lastModified: 0,
699 },
700 ],
701 });
702 mocks.size.mockReturnValue(620_992);
703​
704 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
705 files: [
706 {
707 id: "attachment-id",
708 type: "file",
709 name: "preview-h264.mp4",
710 mimeType: "video/mp4",
711 sizeBytes: 620_992,
712 fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",
713 },
714 ],
715 error: null,
716 });
717 expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true });
718 expect(mocks.copy).toHaveBeenCalledWith(
719 uri,
720 "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",
721 );
722 expect(mocks.delete).not.toHaveBeenCalled();
723 });
724​
725 it("persists provider selections that require a readable cache copy", async () => {
726 const providerUri = "content://cloud-provider/documents/clip";
727 const cachedUri = "file:///cache/DocumentPicker/clip.mp4";
728 mocks.pickFile.mockImplementation(async (options) => ({
729 canceled: false,
730 assets: [
731 {
732 uri: options.copyToCacheDirectory ? cachedUri : providerUri,
733 name: "Cloud recording.mp4",
734 mimeType: "video/mp4",
735 size: 42,
736 lastModified: 0,
737 },
738 ],
739 }));
740 mocks.copy.mockImplementation((uri: string) => {
741 if (uri === providerUri) throw new Error("The provider URI is not directly readable.");
742 });
743​
744 const result = await pickComposerFiles({ existingCount: 0 });
745​
746 expect(result.error).toBeNull();
747 expect(result.files).toEqual([
748 expect.objectContaining({
749 name: "Cloud recording.mp4",
750 fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4",
751 }),
752 ]);
753 expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri);
754 });
755​
756 it("ends the foreground handoff when the picker is canceled without copying files", async () => {
757 mocks.pickFile.mockImplementation(async () => {
758 expect(isForegroundHandoffActive()).toBe(true);
759 return { canceled: true, assets: null };
760 });
761​
762 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
763 files: [],
764 error: null,
765 });
766​
767 expect(isForegroundHandoffActive()).toBe(false);
768 expect(mocks.copy).not.toHaveBeenCalled();
769 expect(mocks.open).not.toHaveBeenCalled();
770 });
771​
772 it("does not open the picker when the draft has no remaining attachment slots", async () => {
773 await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({
774 files: [],
775 error: "You can attach up to 100 files per message.",
776 });
777​
778 expect(mocks.pickFile).not.toHaveBeenCalled();
779 expect(isForegroundHandoffActive()).toBe(false);
780 });
781​
782 it("rejects files that exceed the environment's advertised upload limit", async () => {
783 mocks.pickFile.mockResolvedValue({
784 canceled: false,
785 assets: [
786 {
787 uri: "file:///downloads/archive.zip",
788 name: "archive.zip",
789 mimeType: "application/zip",
790 size: 2 * 1024 * 1024,
791 },
792 ],
793 });
794​
795 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
796 files: [],
797 error: "'archive.zip' exceeds the 1 MB attachment limit.",
798 });
799 expect(mocks.copy).not.toHaveBeenCalled();
800 });
801​
802 it("rejects a file that grew after the picker reported its size", async () => {
803 mocks.pickFile.mockResolvedValue({
804 canceled: false,
805 assets: [
806 {
807 uri: "file:///downloads/archive.zip",
808 name: "archive.zip",
809 mimeType: "application/zip",
810 size: 42,
811 },
812 ],
813 });
814 mocks.size.mockReturnValue(2 * 1024 * 1024);
815​
816 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
817 files: [],
818 error: "'archive.zip' exceeds the 1 MB attachment limit.",
819 });
820 expect(mocks.copy).not.toHaveBeenCalled();
821 });
822​
823 it("stops copying an unknown-size content URI when it exceeds the attachment limit", async () => {
824 const maxBytes = 1024 * 1024;
825 let remainingBytes = maxBytes + 1;
826 const source = {
827 readBytes: vi.fn((length: number) => {
828 const size = Math.min(length, remainingBytes);
829 remainingBytes -= size;
830 return new Uint8Array(size);
831 }),
832 close: vi.fn(),
833 };
834 const destination = { writeBytes: vi.fn(), close: vi.fn() };
835 mocks.open.mockImplementation((uri: string) =>
836 uri.startsWith("content:") ? source : destination,
837 );
838​
839 await expect(
840 persistComposerAttachmentFile("content://shared/large", "large.bin", maxBytes),
841 ).rejects.toThrow("'large.bin' exceeds the 1 MB attachment limit.");
842​
843 expect(source.close).toHaveBeenCalledOnce();
844 expect(destination.close).toHaveBeenCalledOnce();
845 expect(mocks.delete).toHaveBeenCalledWith(
846 "file:///documents/t3-composer-attachments/attachment-id-large.bin",
847 );
848 expect(mocks.copy).not.toHaveBeenCalled();
849 });
850​
851 it("rejects a copy that delivered more bytes than the source reported", async () => {
852 const maxBytes = 1024 * 1024;
853 mocks.size.mockImplementation((uri: string) =>
854 uri.startsWith("content:") ? 42 : 2 * 1024 * 1024,
855 );
856​
857 await expect(
858 persistComposerAttachmentFile("content://shared/liar", "liar.bin", maxBytes),
859 ).rejects.toThrow("'liar.bin' exceeds the 1 MB attachment limit.");
860​
861 expect(mocks.copy).toHaveBeenCalledOnce();
862 expect(mocks.delete).toHaveBeenCalledWith(
863 "file:///documents/t3-composer-attachments/attachment-id-liar.bin",
864 );
865 });
866​
867 it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => {
868 const reader = {
869 readBytes: vi
870 .fn()
871 .mockReturnValueOnce(new Uint8Array(42))
872 .mockReturnValueOnce(new Uint8Array()),
873 close: vi.fn(),
874 };
875 const writer = { writeBytes: vi.fn(), close: vi.fn() };
876 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? 0 : 42));
877 mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer));
878 mocks.pickFile.mockResolvedValue({
879 canceled: false,
880 assets: [
881 {
882 uri: "content://shared/report",
883 name: "report.pdf",
884 mimeType: "application/pdf",
885 size,
886 },
887 ],
888 });
889​
890 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
891 files: [
892 {
893 id: "attachment-id",
894 type: "file",
895 name: "report.pdf",
896 mimeType: "application/pdf",
897 sizeBytes: 42,
898 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
899 },
900 ],
901 error: null,
902 });
903 });
904​
905 it("uses the remaining slot for the first valid file after an oversized selection", async () => {
906 mocks.pickFile.mockResolvedValue({
907 canceled: false,
908 assets: [
909 {
910 uri: "file:///downloads/huge.zip",
911 name: "huge.zip",
912 mimeType: "application/zip",
913 size: 2 * 1024 * 1024,
914 },
915 {
916 uri: "file:///downloads/report.pdf",
917 name: "report.pdf",
918 mimeType: "application/pdf",
919 size: 42,
920 },
921 ],
922 });
923​
924 const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 });
925​
926 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
927 });
928​
929 it("removes the partial destination file when a copy fails midway", async () => {
930 mocks.copy.mockImplementation(() => {
931 throw new Error("disk full");
932 });
933​
934 await expect(
935 persistComposerAttachmentFile("file:///downloads/report.pdf", "report.pdf"),
936 ).rejects.toThrow("disk full");
937​
938 expect(mocks.delete).toHaveBeenCalledWith(
939 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
940 );
941 });
942​
943 it("deletes app-owned attachments without touching user-owned files", async () => {
944 await removePersistedComposerAttachmentFile(
945 "file:///documents/t3-composer-attachments/report.pdf",
946 );
947 await removePersistedComposerAttachmentFile("file:///downloads/report.pdf");
948​
949 expect(mocks.delete).toHaveBeenCalledOnce();
950 expect(mocks.delete).toHaveBeenCalledWith(
951 "file:///documents/t3-composer-attachments/report.pdf",
952 );
953 });
954​
955 it("removes a restored attachment from the current iOS document container", async () => {
956 const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf";
957 const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
958 mocks.documentUri =
959 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
960​
961 await removePersistedComposerAttachmentFile(oldUri);
962 await removePersistedComposerAttachmentFile(
963 `file:///var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${fileName}`,
964 );
965 await removePersistedComposerAttachmentFile(
966 `${mocks.documentUri}/t3-composer-attachments/..%2F..%2Fsender.pdf`,
967 );
968​
969 expect(mocks.delete.mock.calls).toEqual([
970 [`${mocks.documentUri}/t3-composer-attachments/${fileName}`],
971 ]);
972 });
973​
974 it("rechecks preview ownership after loading the native filesystem", async () => {
975 const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4";
976 const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
977 mocks.documentUri =
978 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
979 const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`;
980​
981 const deleting = removePersistedComposerAttachmentFile(oldUri);
982 const release = retainComposerAttachmentFile(currentUri, () => {});
983 try {
984 await deleting;
985 expect(mocks.delete).not.toHaveBeenCalled();
986 } finally {
987 release();
988 }
989​
990 await removePersistedComposerAttachmentFile(oldUri);
991 expect(mocks.delete.mock.calls).toEqual([[currentUri]]);
992 });
993​
994 it("copies an open-in-place source from its actual container without rebasing it", async () => {
995 const sourceUri =
996 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf";
997 mocks.documentUri =
998 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
999​
1000 await persistComposerAttachmentFile(sourceUri, "report.pdf");
1001​
1002 expect(mocks.copy).toHaveBeenCalledWith(
1003 sourceUri,
1004 `${mocks.documentUri}/t3-composer-attachments/attachment-id-report.pdf`,
1005 );
1006 expect(mocks.delete).not.toHaveBeenCalled();
1007 });
1008});