1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 directories: new Set<string>(),
5 deleted: vi.fn(),
6 download: vi.fn(),
7 copy: vi.fn(),
8 share: vi.fn(),
9 shareFromSource: vi.fn(),
10 available: vi.fn(),
11 open: vi.fn(),
12 uuid: vi.fn(),
13}));
14
15vi.mock("expo-file-system", () => {
16 class Directory {
17 readonly uri: string;
18
19 constructor(...parts: Array<string | Directory>) {
20 this.uri = parts.map((part) => (typeof part === "string" ? part : part.uri)).join("/");
21 }
22
23 get name(): string {
24 return this.uri.split("/").at(-1)!;
25 }
26
27 get exists(): boolean {
28 return mocks.directories.has(this.uri);
29 }
30
31 create(): void {
32 mocks.directories.add(this.uri);
33 }
34
35 list(): Directory[] {
36 const prefix = `${this.uri}/`;
37 return [...mocks.directories]
38 .filter((uri) => uri.startsWith(prefix) && !uri.slice(prefix.length).includes("/"))
39 .map((uri) => new Directory(uri));
40 }
41
42 delete(): void {
43 mocks.deleted(this.uri);
44 mocks.directories.delete(this.uri);
45 }
46 }
47
48 class File {
49 static downloadFileAsync = mocks.download;
50 readonly uri: string;
51
52 constructor(source: Directory | string, name?: string) {
53 this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`;
54 }
55
56 async copy(destination: File): Promise<void> {
57 await mocks.copy(this.uri, destination.uri);
58 }
59 }
60
61 return { Directory, File, Paths: { cache: "file:///cache" } };
62});
63
64vi.mock("expo", () => ({ requireNativeModule: () => ({ openFile: mocks.open }) }));
65
66vi.mock("expo-sharing", () => ({
67 isAvailableAsync: mocks.available,
68 shareAsync: mocks.share,
69}));
70
71vi.mock("./uuid", () => ({ uuidv4: mocks.uuid }));
72vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource }));
73
74import {
75 openAttachmentInViewer,
76 downloadAndShareAttachment,
77 downloadAttachmentForPreview,
78 shareLocalAttachment,
79} from "./attachmentDownload";
80import { isForegroundHandoffActive } from "./foreground-handoff";
81
82const NOW = 1_787_990_400_000;
83const DAY_MS = 24 * 60 * 60_000;
84const CACHE = "file:///cache/t3-attachment-downloads";
85const input = {
86 url: "https://chosen-environment.example/api/assets/signed-token/report.pdf",
87 attachment: { name: "report.pdf", mimeType: "application/pdf" },
88};
89
90beforeEach(() => {
91 mocks.open.mockReset();
92 mocks.open.mockResolvedValue(undefined);
93 mocks.directories.clear();
94 mocks.deleted.mockReset();
95 mocks.download.mockReset();
96 mocks.copy.mockReset();
97 mocks.share.mockReset();
98 mocks.shareFromSource.mockReset();
99 mocks.available.mockReset();
100 mocks.uuid.mockReset();
101 mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file);
102 mocks.copy.mockResolvedValue(undefined);
103 mocks.share.mockResolvedValue(undefined);
104 mocks.shareFromSource.mockResolvedValue(undefined);
105 mocks.available.mockResolvedValue(true);
106 let sequence = 0;
107 mocks.uuid.mockImplementation(
108 () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`,
109 );
110 vi.spyOn(Date, "now").mockReturnValue(NOW);
111});
112
113afterEach(() => {
114 vi.restoreAllMocks();
115 expect(isForegroundHandoffActive()).toBe(false);
116});
117
118describe("downloadAndShareAttachment", () => {
119 it("downloads the chosen environment's signed URL and shares the local file", async () => {
120 const controller = new AbortController();
121 await downloadAndShareAttachment({ ...input, signal: controller.signal });
122
123 expect(mocks.download).toHaveBeenCalledWith(
124 input.url,
125 expect.objectContaining({ uri: expect.stringMatching(/\/report\.pdf$/) }),
126 { signal: controller.signal },
127 );
128 expect(mocks.share).toHaveBeenCalledWith(
129 expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/),
130 {
131 mimeType: "application/pdf",
132 dialogTitle: "report.pdf",
133 },
134 );
135 expect(mocks.deleted).not.toHaveBeenCalled();
136 });
137
138 it("shares videos even when the server serves their bytes inline", async () => {
139 await downloadAndShareAttachment({
140 url: "https://relay-environment.example/api/assets/signed-video/clip.mp4",
141 attachment: { name: "clip.mp4", mimeType: 'video/mp4; codecs="avc1"' },
142 signal: new AbortController().signal,
143 });
144
145 expect(mocks.share).toHaveBeenCalledWith(expect.stringMatching(/\/clip\.mp4$/), {
146 mimeType: "video/mp4",
147 dialogTitle: "clip.mp4",
148 });
149 });
150
151 it.each([
152 ["../../résumé.pdf", "résumé.pdf"],
153 ["C:\\folder\\clip.mp4", "clip.mp4"],
154 ["a?query#part%2F.txt", "a?query#part%2F.txt"],
155 ["Report #5 - 100%.pdf", "Report #5 - 100%.pdf"],
156 ["..", "attachment"],
157 [" ", "attachment"],
158 ["\ud800file\u0000.txt", "_file_.txt"],
159 [".env", ".env"],
160 ])("uses a safe basename for %j", async (name, expected) => {
161 await downloadAndShareAttachment({
162 ...input,
163 attachment: { ...input.attachment, name },
164 signal: new AbortController().signal,
165 });
166 const file = mocks.download.mock.calls[0]![1] as { uri: string };
167 expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(expected);
168 });
169
170 it("preserves ordinary long filenames that fit within the filesystem limit", async () => {
171 const name =
172 "Project quarterly report with detailed implementation and delivery notes for August 2026.pdf";
173 await downloadAndShareAttachment({
174 ...input,
175 attachment: { ...input.attachment, name },
176 signal: new AbortController().signal,
177 });
178 const file = mocks.download.mock.calls[0]![1] as { uri: string };
179 expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(name);
180 });
181
182 it("bounds the UTF-8 filename length while preserving its extension", async () => {
183 await downloadAndShareAttachment({
184 ...input,
185 attachment: { name: `${"🙂".repeat(80)}.mp4`, mimeType: "video/mp4" },
186 signal: new AbortController().signal,
187 });
188 const file = mocks.download.mock.calls[0]![1] as { uri: string };
189 const name = decodeURIComponent(file.uri.split("/").at(-1)!);
190 expect(name.endsWith(".mp4")).toBe(true);
191 expect(new TextEncoder().encode(name).length).toBeLessThanOrEqual(255);
192 });
193
194 it("reports unavailable sharing before downloading or creating files", async () => {
195 mocks.available.mockResolvedValue(false);
196 await expect(
197 downloadAndShareAttachment({ ...input, signal: new AbortController().signal }),
198 ).rejects.toThrow("Saving and sharing files is unavailable on this device.");
199 expect(mocks.download).not.toHaveBeenCalled();
200 expect(mocks.directories.size).toBe(0);
201 });
202
203 it("cleans an interrupted download only after the native request settles", async () => {
204 const started = Promise.withResolvers<void>();
205 const download = Promise.withResolvers<{ uri: string }>();
206 mocks.download.mockImplementation(() => {
207 started.resolve();
208 return download.promise;
209 });
210 const controller = new AbortController();
211 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
212 await started.promise;
213 controller.abort();
214 expect(mocks.deleted).not.toHaveBeenCalled();
215 download.reject(new Error("Canceled native request"));
216 await task;
217 expect(mocks.deleted).toHaveBeenCalledTimes(1);
218 expect(mocks.share).not.toHaveBeenCalled();
219 });
220
221 it("does not open a late download after cancellation", async () => {
222 const started = Promise.withResolvers<{ uri: string }>();
223 const download = Promise.withResolvers<{ uri: string }>();
224 mocks.download.mockImplementation((_url: string, file: { uri: string }) => {
225 started.resolve(file);
226 return download.promise;
227 });
228 const controller = new AbortController();
229 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
230 const file = await started.promise;
231 controller.abort();
232 download.resolve(file);
233 await task;
234 expect(mocks.share).not.toHaveBeenCalled();
235 expect(mocks.deleted).toHaveBeenCalledTimes(1);
236 });
237
238 it("retains an export when its row unmounts during the native handoff", async () => {
239 const opened = Promise.withResolvers<void>();
240 const share = Promise.withResolvers<void>();
241 mocks.share.mockImplementation(() => {
242 expect(isForegroundHandoffActive()).toBe(true);
243 opened.resolve();
244 return share.promise;
245 });
246 const controller = new AbortController();
247 const task = downloadAndShareAttachment({ ...input, signal: controller.signal });
248 await opened.promise;
249 controller.abort();
250 share.resolve();
251 await task;
252 expect(mocks.deleted).not.toHaveBeenCalled();
253 });
254
255 it("cleans failed exports and releases the foreground handoff", async () => {
256 mocks.share.mockRejectedValue(new Error("No activity can open this file"));
257 await expect(
258 downloadAndShareAttachment({ ...input, signal: new AbortController().signal }),
259 ).rejects.toThrow("Could not open the share sheet. Try again.");
260 expect(mocks.deleted).toHaveBeenCalledTimes(1);
261 });
262
263 it("removes expired exports while leaving recent and unrelated cache entries alone", async () => {
264 const old = `${CACHE}/${NOW - DAY_MS - 1}-00000000-0000-4000-8000-000000000010`;
265 const recent = `${CACHE}/${NOW - DAY_MS + 1}-00000000-0000-4000-8000-000000000011`;
266 const unrelated = `${CACHE}/unrelated`;
267 mocks.directories.add(old).add(recent).add(unrelated);
268
269 await downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
270 expect(mocks.deleted.mock.calls).toEqual([[old]]);
271 expect(mocks.directories.has(recent)).toBe(true);
272 expect(mocks.directories.has(unrelated)).toBe(true);
273 });
274
275 it("does not prune an active export even if it passes the cache expiry", async () => {
276 const opened = Promise.withResolvers<void>();
277 const share = Promise.withResolvers<void>();
278 mocks.share.mockImplementationOnce(() => {
279 opened.resolve();
280 return share.promise;
281 });
282 const first = downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
283 await opened.promise;
284 vi.mocked(Date.now).mockReturnValue(NOW + DAY_MS + 1);
285
286 await downloadAndShareAttachment({ ...input, signal: new AbortController().signal });
287 expect(mocks.deleted).not.toHaveBeenCalled();
288 share.resolve();
289 await first;
290 });
291});
292
293describe("attachment preview files", () => {
294 it("does not start a native request after cancellation during setup", async () => {
295 const controller = new AbortController();
296 const loading = downloadAttachmentForPreview({ ...input, signal: controller.signal });
297 controller.abort();
298 await expect(loading).resolves.toBeNull();
299 expect(mocks.download).not.toHaveBeenCalled();
300 expect(mocks.share).not.toHaveBeenCalled();
301 });
302
303 it("downloads for playback without requiring a share sheet and removes the file on close", async () => {
304 mocks.available.mockResolvedValue(false);
305 const file = await downloadAttachmentForPreview({
306 ...input,
307 signal: new AbortController().signal,
308 });
309 expect(file?.uri.endsWith("/report.pdf")).toBe(true);
310 expect(mocks.available).not.toHaveBeenCalled();
311 expect(mocks.deleted).not.toHaveBeenCalled();
312 file?.dispose();
313 file?.dispose();
314 expect(mocks.deleted).toHaveBeenCalledTimes(1);
315 });
316
317 it.each([undefined, "share-button"])(
318 "keeps a shared preview after its owner closes (source: %s)",
319 async (sourceIdentifier) => {
320 const opened = Promise.withResolvers<void>();
321 const sharing = Promise.withResolvers<void>();
322 const nativeShare = sourceIdentifier ? mocks.shareFromSource : mocks.share;
323 nativeShare.mockImplementationOnce(() => {
324 opened.resolve();
325 return sharing.promise;
326 });
327 const file = await downloadAttachmentForPreview({
328 ...input,
329 signal: new AbortController().signal,
330 });
331 const share = file!.share(new AbortController().signal, sourceIdentifier);
332 await opened.promise;
333 file!.dispose();
334 expect(mocks.deleted).not.toHaveBeenCalled();
335 expect(isForegroundHandoffActive()).toBe(true);
336 sharing.resolve();
337 await share;
338 expect(isForegroundHandoffActive()).toBe(false);
339 expect(mocks.deleted).not.toHaveBeenCalled();
340 expect(mocks.download).toHaveBeenCalledTimes(1);
341 expect(mocks.copy).not.toHaveBeenCalled();
342 },
343 );
344
345 it.each([undefined, "share-button"])(
346 "does not share a disposed preview after availability checking (source: %s)",
347 async (sourceIdentifier) => {
348 const checking = Promise.withResolvers<void>();
349 const available = Promise.withResolvers<boolean>();
350 mocks.available.mockImplementation(() => {
351 checking.resolve();
352 return available.promise;
353 });
354 const file = await downloadAttachmentForPreview({
355 ...input,
356 signal: new AbortController().signal,
357 });
358 const share = file!.share(new AbortController().signal, sourceIdentifier);
359 await checking.promise;
360 file!.dispose();
361 available.resolve(true);
362 await share;
363 expect(mocks.share).not.toHaveBeenCalled();
364 expect(mocks.shareFromSource).not.toHaveBeenCalled();
365 expect(mocks.deleted).toHaveBeenCalledTimes(1);
366 },
367 );
368
369 it("copies a local original before sharing without downloading or deleting the source", async () => {
370 const uri = "file:///documents/draft/report.pdf";
371 await shareLocalAttachment({
372 uri,
373 attachment: input.attachment,
374 signal: new AbortController().signal,
375 });
376 expect(mocks.copy).toHaveBeenCalledWith(
377 uri,
378 expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/),
379 );
380 expect(mocks.share).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], expect.any(Object));
381 expect(mocks.download).not.toHaveBeenCalled();
382 expect(mocks.deleted).not.toHaveBeenCalled();
383 });
384
385 it("waits for a local copy to finish before cleaning up a canceled share", async () => {
386 const copying = Promise.withResolvers<void>();
387 const copied = Promise.withResolvers<void>();
388 mocks.copy.mockImplementation(() => {
389 copying.resolve();
390 return copied.promise;
391 });
392 const controller = new AbortController();
393 const task = shareLocalAttachment({
394 uri: "file:///documents/draft/report.pdf",
395 attachment: input.attachment,
396 signal: controller.signal,
397 });
398 await copying.promise;
399 controller.abort();
400 expect(mocks.deleted).not.toHaveBeenCalled();
401 copied.resolve();
402 await task;
403 expect(mocks.share).not.toHaveBeenCalled();
404 expect(mocks.deleted).toHaveBeenCalledTimes(1);
405 });
406});
407
408describe("document viewer handoff", () => {
409 it("opens a cache copy with its MIME type and retains it for the viewer", async () => {
410 await openAttachmentInViewer({
411 uri: "file:///documents/report.pdf",
412 attachment: input.attachment,
413 signal: new AbortController().signal,
414 });
415 expect(mocks.open).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], "application/pdf");
416 expect(mocks.share).not.toHaveBeenCalled();
417 expect(mocks.deleted).not.toHaveBeenCalled();
418 });
419 it("cleans up when no viewer handles the document", async () => {
420 mocks.open.mockRejectedValue(new Error("No viewer"));
421 await expect(
422 openAttachmentInViewer({
423 uri: input.url,
424 attachment: input.attachment,
425 signal: new AbortController().signal,
426 }),
427 ).rejects.toThrow("No viewer");
428 expect(mocks.download).toHaveBeenCalledTimes(1);
429 expect(mocks.deleted).toHaveBeenCalledTimes(1);
430 expect(isForegroundHandoffActive()).toBe(false);
431 });
432});