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 pickComposerFiles,
94 pickComposerImages,
95 pickComposerMedia,
96 removePersistedComposerAttachmentFile,
97} from "./composerImages";
98import { isForegroundHandoffActive } from "./foreground-handoff";
99import { retainComposerAttachmentFile } from "./composerAttachmentFiles";
100
101describe("composer file attachments", () => {
102 beforeEach(() => {
103 mocks.documentUri = "file:///documents";
104 mocks.pickFile.mockReset();
105 mocks.pickMedia.mockReset();
106 mocks.copy.mockReset();
107 mocks.delete.mockReset();
108 mocks.open.mockReset();
109 mocks.size.mockReset();
110 mocks.readBase64.mockReset();
111 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
112 });
113
114 describe("photo library image conversion", () => {
115 const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" };
116 const photo: ImagePickerAsset = {
117 uri: "file:///picker/photo.heic",
118 type: "image",
119 fileName: "photo.HEIC",
120 mimeType: "image/heic",
121 fileSize: 4 * 1024 * 1024,
122 width: 4032,
123 height: 3024,
124 };
125 /**
126 * Stands in for the native manipulator: `size` is what decoding the source yields,
127 * `resizes` records every requested resize, and `saved` is what saving returns.
128 */
129 const native = {
130 size: { width: 1, height: 1 },
131 resizes: [] as Array<{ width?: number | null; height?: number | null }>,
132 saved: rendered as { uri: string; base64?: string },
133 };
134
135 beforeEach(() => {
136 native.size = { width: 1, height: 1 };
137 native.resizes = [];
138 native.saved = rendered;
139 mocks.manipulate.mockReset();
140 mocks.release.mockReset();
141 mocks.manipulate.mockImplementation(() => {
142 const context = {
143 resize(size: { width?: number | null; height?: number | null }) {
144 native.resizes.push(size);
145 return context;
146 },
147 renderAsync: async () => ({
148 ...native.size,
149 release: mocks.release,
150 saveAsync: async () => native.saved,
151 }),
152 };
153 return context;
154 });
155 });
156
157 it.each(["image/heic", "image/heif", undefined])(
158 "renders a %s photo to JPEG natively and previews the rendered file",
159 async (mimeType) => {
160 mocks.pickMedia.mockResolvedValue({
161 canceled: false,
162 assets: [{ ...photo, mimeType }],
163 });
164
165 const result = await pickComposerImages({ existingCount: 0 });
166
167 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false }));
168 expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
169 expect(mocks.readBase64).not.toHaveBeenCalled();
170 expect(result).toEqual({
171 images: [
172 {
173 id: "attachment-id",
174 type: "image",
175 name: "photo.jpg",
176 mimeType: "image/jpeg",
177 sizeBytes: 4,
178 dataUrl: `data:image/jpeg;base64,${rendered.base64}`,
179 previewUri: rendered.uri,
180 },
181 ],
182 error: null,
183 });
184 },
185 );
186
187 it.each([
188 { size: { width: 4032, height: 3024 }, resizes: [{ width: 2000, height: 1500 }] },
189 { size: { width: 3024, height: 4032 }, resizes: [{ width: 1500, height: 2000 }] },
190 { size: { width: 2000, height: 1500 }, resizes: [] },
191 ])("bounds a $size.width x $size.height photo to a 2000 px longest edge", async (input) => {
192 native.size = input.size;
193 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });
194
195 await pickComposerImages({ existingCount: 0 });
196
197 expect(native.resizes).toEqual(input.resizes);
198 // Every decoded bitmap is released, including the full-size one a resize replaces.
199 expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1);
200 });
201
202 it.each([
203 { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },
204 { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },
205 { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },
206 ])("keeps original $extension bytes from the picker file", async (original) => {
207 const name = `photo.${original.extension}`;
208 mocks.pickMedia.mockResolvedValue({
209 canceled: false,
210 assets: [{ ...photo, fileName: name, mimeType: original.mimeType }],
211 });
212 mocks.readBase64.mockResolvedValue(original.base64);
213
214 const result = await pickComposerImages({ existingCount: 0 });
215
216 expect(mocks.manipulate).not.toHaveBeenCalled();
217 expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri);
218 expect(result.error).toBeNull();
219 expect(result.images).toEqual([
220 expect.objectContaining({
221 name,
222 mimeType: original.mimeType,
223 dataUrl: `data:${original.mimeType};base64,${original.base64}`,
224 sizeBytes: Buffer.from(original.base64, "base64").byteLength,
225 previewUri: photo.uri,
226 }),
227 ]);
228 });
229
230 it("renders a supported original that exceeds the image limit instead of rejecting it", async () => {
231 mocks.pickMedia.mockResolvedValue({
232 canceled: false,
233 assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }],
234 });
235 mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);
236
237 const result = await pickComposerImages({ existingCount: 0 });
238
239 expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
240 expect(result.error).toBeNull();
241 expect(result.images).toEqual([
242 expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }),
243 ]);
244 });
245
246 it("measures the picker file instead of trusting the reported size", async () => {
247 // A content stream can deliver more bytes than the picker advertises; a supported
248 // original only skips rendering when the file itself measures within the limit.
249 mocks.pickMedia.mockResolvedValue({
250 canceled: false,
251 assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }],
252 });
253 mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);
254
255 const result = await pickComposerImages({ existingCount: 0 });
256
257 expect(mocks.readBase64).not.toHaveBeenCalled();
258 expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri);
259 expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
260 });
261
262 it("renders a supported original whose size cannot be measured", async () => {
263 mocks.pickMedia.mockResolvedValue({
264 canceled: false,
265 assets: [
266 { ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" },
267 ],
268 });
269
270 const result = await pickComposerImages({ existingCount: 0 });
271
272 expect(mocks.readBase64).not.toHaveBeenCalled();
273 expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1");
274 expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]);
275 });
276
277 it("checks the rendered JPEG against the image limit", async () => {
278 native.saved = {
279 uri: rendered.uri,
280 base64:
281 rendered.base64.slice(0, 4) +
282 "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4),
283 };
284 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });
285
286 await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
287 images: [],
288 error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
289 });
290 });
291
292 it("retains a rendered photo when another original cannot be read", async () => {
293 mocks.pickMedia.mockResolvedValue({
294 canceled: false,
295 assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],
296 });
297 mocks.readBase64.mockRejectedValue(new Error("missing file"));
298
299 const result = await pickComposerImages({ existingCount: 0 });
300
301 expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);
302 expect(result.error).toBe("Failed to read 'missing.gif'.");
303 });
304
305 it("reports a photo the native renderer cannot decode", async () => {
306 mocks.manipulate.mockImplementation(() => ({
307 resize: () => {
308 throw new Error("unreachable");
309 },
310 renderAsync: () => Promise.reject(new Error("corrupt")),
311 }));
312 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] });
313
314 await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
315 images: [],
316 error: "Failed to read 'photo.HEIC'.",
317 });
318 });
319 });
320
321 describe("photo library videos", () => {
322 const image: ImagePickerAsset = {
323 uri: "file:///picker/photo.png",
324 type: "image",
325 fileName: "photo.png",
326 mimeType: "image/png",
327 fileSize: 3,
328 width: 1,
329 height: 1,
330 };
331
332 beforeEach(() => {
333 mocks.readBase64.mockResolvedValue("YWJj");
334 });
335 const video: ImagePickerAsset = {
336 uri: "file:///picker/clip.mov",
337 type: "video",
338 fileName: "clip.mov",
339 mimeType: "video/quicktime",
340 fileSize: 20 * 1024 * 1024,
341 base64: null,
342 width: 1920,
343 height: 1080,
344 };
345
346 it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => {
347 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
348 mocks.size.mockImplementation((uri: string) =>
349 uri.endsWith("clip.mov") ? video.fileSize : 3,
350 );
351
352 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });
353
354 expect(mocks.pickMedia).toHaveBeenCalledWith(
355 expect.objectContaining({
356 mediaTypes: ["images", "videos"],
357 shouldDownloadFromNetwork: true,
358 }),
359 );
360 expect(result).toEqual({
361 attachments: [
362 expect.objectContaining({ type: "image", dataUrl: "data:image/png;base64,YWJj" }),
363 {
364 id: "attachment-id",
365 type: "file",
366 name: "clip.mov",
367 mimeType: "video/quicktime",
368 sizeBytes: video.fileSize,
369 fileUri: "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
370 },
371 ],
372 error: null,
373 });
374 expect(mocks.copy).toHaveBeenCalledWith(
375 video.uri,
376 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
377 );
378 expect(mocks.delete).not.toHaveBeenCalled();
379 });
380
381 it("keeps image-only destinations on the image picker path", async () => {
382 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image] });
383
384 const result = await pickComposerImages({ existingCount: 0 });
385
386 expect(mocks.pickMedia).toHaveBeenCalledWith(
387 expect.objectContaining({ mediaTypes: ["images"] }),
388 );
389 expect(result.images).toEqual([
390 expect.objectContaining({ type: "image", name: "photo.png" }),
391 ]);
392 expect(result.error).toBeNull();
393 expect(mocks.copy).not.toHaveBeenCalled();
394 });
395
396 it("does not persist videos when the destination lacks file support", async () => {
397 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] });
398
399 const result = await pickComposerMedia({ existingCount: 0 });
400
401 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
402 expect(result.error).toBe("Video attachments are unavailable here.");
403 expect(mocks.copy).not.toHaveBeenCalled();
404 });
405
406 it("uses local video metadata when the picker omits its name, MIME type, or size", async () => {
407 mocks.pickMedia.mockResolvedValue({
408 canceled: false,
409 assets: [{ ...video, fileName: null, mimeType: undefined, fileSize: undefined }],
410 });
411
412 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 });
413
414 expect(result.error).toBeNull();
415 expect(result.attachments).toEqual([
416 expect.objectContaining({
417 type: "file",
418 name: "clip.mov",
419 mimeType: "video/quicktime",
420 sizeBytes: 42,
421 }),
422 ]);
423 });
424
425 it.each([
426 {
427 reason: "picker size exceeds the server limit",
428 reported: 2 * 1024 * 1024,
429 stored: 42,
430 limit: 1024 * 1024,
431 error: "'clip.mov' exceeds the 1 MB attachment limit.",
432 },
433 {
434 reason: "actual size exceeds the server limit",
435 reported: 42,
436 stored: 2 * 1024 * 1024,
437 limit: 1024 * 1024,
438 error: "'clip.mov' exceeds the 1 MB attachment limit.",
439 },
440 {
441 reason: "stored copy is empty",
442 reported: 42,
443 stored: 0,
444 limit: 1024 * 1024,
445 error: "'clip.mov' is empty or could not be read.",
446 },
447 {
448 reason: "server advertises more than the contract limit",
449 reported: 51 * 1024 * 1024,
450 stored: 42,
451 limit: 80 * 1024 * 1024,
452 error: "'clip.mov' exceeds the 50 MB attachment limit.",
453 },
454 ])(
455 "rejects a video when $reason while retaining the selected photo",
456 async ({ reported, stored, limit, error }) => {
457 mocks.pickMedia.mockResolvedValue({
458 canceled: false,
459 assets: [{ ...video, fileSize: reported }, image],
460 });
461 mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3));
462
463 const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit });
464
465 expect(result).toEqual({
466 attachments: [expect.objectContaining({ type: "image" })],
467 error,
468 });
469 if (stored === 0) {
470 expect(mocks.delete).toHaveBeenCalledWith(
471 "file:///documents/t3-composer-attachments/attachment-id-clip.mov",
472 );
473 }
474 },
475 );
476
477 it("applies the remaining attachment slots to photos and videos together", async () => {
478 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
479
480 const result = await pickComposerMedia({
481 existingCount: 99,
482 maxVideoBytes: 50 * 1024 * 1024,
483 });
484
485 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
486 expect(result.error).toBe("You can attach up to 100 attachments per message.");
487 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
488 expect(mocks.copy).not.toHaveBeenCalled();
489 });
490
491 it("reports a native video retrieval error and ends the foreground handoff", async () => {
492 mocks.pickMedia.mockRejectedValue(new Error("Could not download video from iCloud."));
493
494 await expect(pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 })).resolves.toEqual({
495 attachments: [],
496 error: "Could not download video from iCloud.",
497 });
498 expect(isForegroundHandoffActive()).toBe(false);
499 });
500 });
501
502 it("copies picked files into app-owned storage without loading their contents", async () => {
503 mocks.pickFile.mockResolvedValue({
504 canceled: false,
505 assets: [
506 {
507 uri: "file:///downloads/report.pdf",
508 name: "report.pdf",
509 mimeType: "application/pdf",
510 size: 42,
511 },
512 ],
513 });
514
515 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
516 files: [
517 {
518 id: "attachment-id",
519 type: "file",
520 name: "report.pdf",
521 mimeType: "application/pdf",
522 sizeBytes: 42,
523 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
524 },
525 ],
526 error: null,
527 });
528 expect(mocks.copy).toHaveBeenCalledWith(
529 "file:///downloads/report.pdf",
530 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
531 );
532 });
533
534 it("preserves Android picker metadata instead of using the content URI document id", async () => {
535 const uri = "content://com.android.providers.media.documents/document/video%3A18";
536 mocks.pickFile.mockResolvedValue({
537 canceled: false,
538 assets: [
539 {
540 uri,
541 name: "preview-h264.mp4",
542 mimeType: "video/mp4",
543 size: 620_992,
544 lastModified: 0,
545 },
546 ],
547 });
548 mocks.size.mockReturnValue(620_992);
549
550 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
551 files: [
552 {
553 id: "attachment-id",
554 type: "file",
555 name: "preview-h264.mp4",
556 mimeType: "video/mp4",
557 sizeBytes: 620_992,
558 fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",
559 },
560 ],
561 error: null,
562 });
563 expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true });
564 expect(mocks.copy).toHaveBeenCalledWith(
565 uri,
566 "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",
567 );
568 expect(mocks.delete).not.toHaveBeenCalled();
569 });
570
571 it("persists provider selections that require a readable cache copy", async () => {
572 const providerUri = "content://cloud-provider/documents/clip";
573 const cachedUri = "file:///cache/DocumentPicker/clip.mp4";
574 mocks.pickFile.mockImplementation(async (options) => ({
575 canceled: false,
576 assets: [
577 {
578 uri: options.copyToCacheDirectory ? cachedUri : providerUri,
579 name: "Cloud recording.mp4",
580 mimeType: "video/mp4",
581 size: 42,
582 lastModified: 0,
583 },
584 ],
585 }));
586 mocks.copy.mockImplementation((uri: string) => {
587 if (uri === providerUri) throw new Error("The provider URI is not directly readable.");
588 });
589
590 const result = await pickComposerFiles({ existingCount: 0 });
591
592 expect(result.error).toBeNull();
593 expect(result.files).toEqual([
594 expect.objectContaining({
595 name: "Cloud recording.mp4",
596 fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4",
597 }),
598 ]);
599 expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri);
600 });
601
602 it("ends the foreground handoff when the picker is canceled without copying files", async () => {
603 mocks.pickFile.mockImplementation(async () => {
604 expect(isForegroundHandoffActive()).toBe(true);
605 return { canceled: true, assets: null };
606 });
607
608 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
609 files: [],
610 error: null,
611 });
612
613 expect(isForegroundHandoffActive()).toBe(false);
614 expect(mocks.copy).not.toHaveBeenCalled();
615 expect(mocks.open).not.toHaveBeenCalled();
616 });
617
618 it("reports picker failures and releases the foreground handoff", async () => {
619 mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable."));
620
621 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
622 files: [],
623 error: "The document provider is unavailable.",
624 });
625
626 expect(isForegroundHandoffActive()).toBe(false);
627 expect(mocks.copy).not.toHaveBeenCalled();
628 });
629
630 it("does not open the picker when the draft has no remaining attachment slots", async () => {
631 await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({
632 files: [],
633 error: "You can attach up to 100 files per message.",
634 });
635
636 expect(mocks.pickFile).not.toHaveBeenCalled();
637 expect(isForegroundHandoffActive()).toBe(false);
638 });
639
640 it("falls back to a usable name when the picker reports a blank one", async () => {
641 mocks.pickFile.mockResolvedValue({
642 canceled: false,
643 assets: [
644 {
645 uri: "file:///downloads/unnamed",
646 name: " ",
647 mimeType: "application/pdf",
648 size: 42,
649 },
650 ],
651 });
652
653 const result = await pickComposerFiles({ existingCount: 0 });
654 expect(result.error).toBeNull();
655 expect(result.files).toHaveLength(1);
656 expect(result.files[0]?.name).toBe("file");
657 });
658
659 it("rejects files that exceed the environment's advertised upload limit", async () => {
660 mocks.pickFile.mockResolvedValue({
661 canceled: false,
662 assets: [
663 {
664 uri: "file:///downloads/archive.zip",
665 name: "archive.zip",
666 mimeType: "application/zip",
667 size: 2 * 1024 * 1024,
668 },
669 ],
670 });
671
672 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
673 files: [],
674 error: "'archive.zip' exceeds the 1 MB attachment limit.",
675 });
676 expect(mocks.copy).not.toHaveBeenCalled();
677 });
678
679 it("never accepts files above the 50 MB contract limit", async () => {
680 mocks.pickFile.mockResolvedValue({
681 canceled: false,
682 assets: [
683 {
684 uri: "file:///downloads/archive.zip",
685 name: "archive.zip",
686 mimeType: "application/zip",
687 size: 51 * 1024 * 1024,
688 },
689 ],
690 });
691
692 await expect(
693 pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }),
694 ).resolves.toEqual({
695 files: [],
696 error: "'archive.zip' exceeds the 50 MB attachment limit.",
697 });
698 });
699
700 it("rejects a file that grew after the picker reported its size", async () => {
701 mocks.pickFile.mockResolvedValue({
702 canceled: false,
703 assets: [
704 {
705 uri: "file:///downloads/archive.zip",
706 name: "archive.zip",
707 mimeType: "application/zip",
708 size: 42,
709 },
710 ],
711 });
712 mocks.size.mockReturnValue(2 * 1024 * 1024);
713
714 await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({
715 files: [],
716 error: "'archive.zip' exceeds the 1 MB attachment limit.",
717 });
718 expect(mocks.copy).not.toHaveBeenCalled();
719 });
720
721 it("stops copying an unknown-size content URI when it exceeds the attachment limit", async () => {
722 const maxBytes = 1024 * 1024;
723 let remainingBytes = maxBytes + 1;
724 const source = {
725 readBytes: vi.fn((length: number) => {
726 const size = Math.min(length, remainingBytes);
727 remainingBytes -= size;
728 return new Uint8Array(size);
729 }),
730 close: vi.fn(),
731 };
732 const destination = { writeBytes: vi.fn(), close: vi.fn() };
733 mocks.open.mockImplementation((uri: string) =>
734 uri.startsWith("content:") ? source : destination,
735 );
736
737 await expect(
738 persistComposerAttachmentFile("content://shared/large", "large.bin", maxBytes),
739 ).rejects.toThrow("'large.bin' exceeds the 1 MB attachment limit.");
740
741 expect(source.close).toHaveBeenCalledOnce();
742 expect(destination.close).toHaveBeenCalledOnce();
743 expect(mocks.delete).toHaveBeenCalledWith(
744 "file:///documents/t3-composer-attachments/attachment-id-large.bin",
745 );
746 expect(mocks.copy).not.toHaveBeenCalled();
747 });
748
749 it("rejects a copy that delivered more bytes than the source reported", async () => {
750 const maxBytes = 1024 * 1024;
751 // An Android content: stream can report a small size and still deliver
752 // more bytes; the persisted copy is what must satisfy the limit.
753 mocks.size.mockImplementation((uri: string) =>
754 uri.startsWith("content:") ? 42 : 2 * 1024 * 1024,
755 );
756
757 await expect(
758 persistComposerAttachmentFile("content://shared/liar", "liar.bin", maxBytes),
759 ).rejects.toThrow("'liar.bin' exceeds the 1 MB attachment limit.");
760
761 expect(mocks.copy).toHaveBeenCalledOnce();
762 expect(mocks.delete).toHaveBeenCalledWith(
763 "file:///documents/t3-composer-attachments/attachment-id-liar.bin",
764 );
765 });
766
767 it("reports an empty file without calling it oversized", async () => {
768 mocks.size.mockReturnValue(0);
769 mocks.pickFile.mockResolvedValue({
770 canceled: false,
771 assets: [
772 {
773 uri: "file:///downloads/empty.txt",
774 name: "empty.txt",
775 mimeType: "text/plain",
776 size: 0,
777 },
778 ],
779 });
780
781 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
782 files: [],
783 error: "'empty.txt' is empty or could not be read.",
784 });
785 });
786
787 it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => {
788 const reader = {
789 readBytes: vi
790 .fn()
791 .mockReturnValueOnce(new Uint8Array(42))
792 .mockReturnValueOnce(new Uint8Array()),
793 close: vi.fn(),
794 };
795 const writer = { writeBytes: vi.fn(), close: vi.fn() };
796 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? 0 : 42));
797 mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer));
798 mocks.pickFile.mockResolvedValue({
799 canceled: false,
800 assets: [
801 {
802 uri: "content://shared/report",
803 name: "report.pdf",
804 mimeType: "application/pdf",
805 size,
806 },
807 ],
808 });
809
810 await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({
811 files: [
812 {
813 id: "attachment-id",
814 type: "file",
815 name: "report.pdf",
816 mimeType: "application/pdf",
817 sizeBytes: 42,
818 fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
819 },
820 ],
821 error: null,
822 });
823 });
824
825 it("uses the remaining slot for the first valid file after an oversized selection", async () => {
826 mocks.pickFile.mockResolvedValue({
827 canceled: false,
828 assets: [
829 {
830 uri: "file:///downloads/huge.zip",
831 name: "huge.zip",
832 mimeType: "application/zip",
833 size: 2 * 1024 * 1024,
834 },
835 {
836 uri: "file:///downloads/report.pdf",
837 name: "report.pdf",
838 mimeType: "application/pdf",
839 size: 42,
840 },
841 ],
842 });
843
844 const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 });
845
846 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
847 });
848
849 it("removes the partial destination file when a copy fails midway", async () => {
850 mocks.copy.mockImplementation(() => {
851 throw new Error("disk full");
852 });
853
854 await expect(
855 persistComposerAttachmentFile("file:///downloads/report.pdf", "report.pdf"),
856 ).rejects.toThrow("disk full");
857
858 expect(mocks.delete).toHaveBeenCalledWith(
859 "file:///documents/t3-composer-attachments/attachment-id-report.pdf",
860 );
861 });
862
863 it("deletes app-owned attachments without touching user-owned files", async () => {
864 await removePersistedComposerAttachmentFile(
865 "file:///documents/t3-composer-attachments/report.pdf",
866 );
867 await removePersistedComposerAttachmentFile("file:///downloads/report.pdf");
868
869 expect(mocks.delete).toHaveBeenCalledOnce();
870 expect(mocks.delete).toHaveBeenCalledWith(
871 "file:///documents/t3-composer-attachments/report.pdf",
872 );
873 });
874
875 it("removes a restored attachment from the current iOS document container", async () => {
876 const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf";
877 const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
878 mocks.documentUri =
879 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
880
881 await removePersistedComposerAttachmentFile(oldUri);
882 await removePersistedComposerAttachmentFile(
883 `file:///var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${fileName}`,
884 );
885 await removePersistedComposerAttachmentFile(
886 `${mocks.documentUri}/t3-composer-attachments/..%2F..%2Fsender.pdf`,
887 );
888
889 expect(mocks.delete.mock.calls).toEqual([
890 [`${mocks.documentUri}/t3-composer-attachments/${fileName}`],
891 ]);
892 });
893
894 it("rechecks preview ownership after loading the native filesystem", async () => {
895 const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4";
896 const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
897 mocks.documentUri =
898 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
899 const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`;
900
901 const deleting = removePersistedComposerAttachmentFile(oldUri);
902 const release = retainComposerAttachmentFile(currentUri, () => {});
903 try {
904 await deleting;
905 expect(mocks.delete).not.toHaveBeenCalled();
906 } finally {
907 release();
908 }
909
910 await removePersistedComposerAttachmentFile(oldUri);
911 expect(mocks.delete.mock.calls).toEqual([[currentUri]]);
912 });
913
914 it("copies an open-in-place source from its actual container without rebasing it", async () => {
915 const sourceUri =
916 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf";
917 mocks.documentUri =
918 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
919
920 await persistComposerAttachmentFile(sourceUri, "report.pdf");
921
922 expect(mocks.copy).toHaveBeenCalledWith(
923 sourceUri,
924 `${mocks.documentUri}/t3-composer-attachments/attachment-id-report.pdf`,
925 );
926 expect(mocks.delete).not.toHaveBeenCalled();
927 });
928});