Skip to content
Source
Sign inSign up

incoming-share-model.test.ts

573 lines18.4 KB

1import { describe, expect, it, vi } from "@effect/vitest";
2import {
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
4 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5} from "@t3tools/contracts";
6import type { ResolvedSharePayload, SharePayload } from "expo-sharing";
7​
8import {
9 buildIncomingShareDraft,
10 hasIncomingShareContent,
11 isShareFileUriUnderOwnedRoots,
12 selectIncomingShareAttachments,
13 selectIncomingShareAttachmentsForServer,
14} from "./incoming-share-model";
15​
16describe("incoming native shares", () => {
17 it("converts shared text, URLs, and images into a durable composer draft", async () => {
18 const image: SharePayload = {
19 shareType: "image",
20 value: "file:///shared/Screenshot.png",
21 mimeType: "image/png",
22 };
23 const payloads: SharePayload[] = [
24 { shareType: "text", value: "Please explain this error" },
25 { shareType: "url", value: "https://example.com/issue/1" },
26 { shareType: "text", value: "Please explain this error" },
27 image,
28 ];
29 const resolvedImage: ResolvedSharePayload = {
30 ...image,
31 contentUri: image.value,
32 contentType: "image",
33 contentMimeType: "image/png",
34 contentSize: 3,
35 originalName: "Screenshot.png",
36 };
37 const removeOwnedFile = vi.fn(() => Promise.resolve());
38​
39 const result = await buildIncomingShareDraft({
40 id: "share-1",
41 createdAt: "2026-07-15T10:00:00.000Z",
42 payloads,
43 resolvedPayloads: [resolvedImage],
44 fileReader: {
45 readBase64: async () => "YWJj",
46 removeOwnedFile,
47 },
48 });
49​
50 expect(result).toEqual({
51 schemaVersion: 1,
52 id: "share-1",
53 createdAt: "2026-07-15T10:00:00.000Z",
54 text: "Please explain this error\n\nhttps://example.com/issue/1",
55 attachments: [
56 {
57 id: "share-1:image:3",
58 type: "image",
59 name: "Screenshot.png",
60 mimeType: "image/png",
61 sizeBytes: 3,
62 dataUrl: "data:image/png;base64,YWJj",
63 previewUri: "data:image/png;base64,YWJj",
64 },
65 ],
66 warnings: [],
67 });
68 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
69 expect(hasIncomingShareContent(result)).toBe(true);
70 });
71​
72 it("skips oversized images and releases the temporary native file", async () => {
73 const image: SharePayload = {
74 shareType: "image",
75 value: "file:///shared/huge.png",
76 mimeType: "image/png",
77 };
78 const readBase64 = vi.fn(async () => "unused");
79 const removeOwnedFile = vi.fn(() => Promise.resolve());
80​
81 const result = await buildIncomingShareDraft({
82 id: "share-2",
83 createdAt: "2026-07-15T10:00:00.000Z",
84 payloads: [image],
85 resolvedPayloads: [
86 {
87 ...image,
88 contentUri: image.value,
89 contentType: "image",
90 contentMimeType: "image/png",
91 contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1,
92 originalName: "huge.png",
93 },
94 ],
95 fileReader: { readBase64, removeOwnedFile },
96 });
97​
98 expect(result.attachments).toEqual([]);
99 expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]);
100 expect(readBase64).not.toHaveBeenCalled();
101 expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
102 expect(hasIncomingShareContent(result)).toBe(false);
103 });
104​
105 it("keeps a shared PDF on disk without converting its contents to base64", async () => {
106 const file: SharePayload = {
107 shareType: "file",
108 value: "file:///shared/report.pdf",
109 mimeType: "application/pdf",
110 };
111 const readBase64 = vi.fn(async () => "unused");
112 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
113 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
114​
115 const result = await buildIncomingShareDraft({
116 id: "share-report",
117 createdAt: "2026-07-15T10:00:00.000Z",
118 payloads: [file],
119 resolvedPayloads: [
120 {
121 ...file,
122 contentUri: file.value,
123 contentType: "file",
124 contentMimeType: "application/pdf",
125 contentSize: 42,
126 originalName: "report.pdf",
127 },
128 ],
129 fileReader: { readBase64, persistFile, removeOwnedFile },
130 });
131​
132 expect(result.attachments).toEqual([
133 {
134 id: "share-report:file:0",
135 type: "file",
136 name: "report.pdf",
137 mimeType: "application/pdf",
138 sizeBytes: 42,
139 fileUri: "file:///documents/report.pdf",
140 },
141 ]);
142 expect(readBase64).not.toHaveBeenCalled();
143 expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf");
144 expect(removeOwnedFile).toHaveBeenCalledWith(file.value);
145 });
146​
147 it.each([
148 { value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" },
149 { value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" },
150 ])("imports a shared video from $value without reading it as an image", async (video) => {
151 const sizeBytes = 20 * 1024 * 1024;
152 const fileUri = `file:///documents/${video.originalName}`;
153 const readBase64 = vi.fn(async () => "unused");
154 const persistFile = vi.fn(async () => fileUri);
155 const removeOwnedFile = vi.fn(async () => undefined);
156​
157 const result = await buildIncomingShareDraft({
158 id: "share-video",
159 createdAt: "2026-08-30T10:00:00.000Z",
160 payloads: [{ ...video, shareType: "video" }],
161 resolvedPayloads: [],
162 fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile },
163 });
164​
165 expect(result.warnings).toEqual([]);
166 expect(result.attachments).toEqual([
167 {
168 id: "share-video:file:0",
169 type: "file",
170 name: video.originalName,
171 mimeType: video.mimeType,
172 sizeBytes,
173 fileUri,
174 },
175 ]);
176 expect(readBase64).not.toHaveBeenCalled();
177 expect(removeOwnedFile).toHaveBeenCalledWith(video.value);
178 expect(
179 selectIncomingShareAttachments({
180 attachments: result.attachments,
181 maxFileAttachmentBytes: 50 * 1024 * 1024,
182 }),
183 ).toEqual({ attachments: result.attachments, warnings: [] });
184 expect(
185 selectIncomingShareAttachments({
186 attachments: result.attachments,
187 maxFileAttachmentBytes: 10 * 1024 * 1024,
188 }),
189 ).toEqual({
190 attachments: [],
191 warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`],
192 });
193 });
194​
195 it("reports an unreadable shared file without calling it oversized", async () => {
196 const file: SharePayload = {
197 shareType: "file",
198 value: "file:///shared/empty.txt",
199 mimeType: "text/plain",
200 };
201​
202 const result = await buildIncomingShareDraft({
203 id: "share-empty",
204 createdAt: "2026-07-15T10:00:00.000Z",
205 payloads: [file],
206 resolvedPayloads: [],
207 fileReader: {
208 readBase64: async () => "unused",
209 readSize: async () => 0,
210 removeOwnedFile: async () => undefined,
211 },
212 });
213​
214 expect(result.attachments).toEqual([]);
215 expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]);
216 });
217​
218 it("reads an Android content URI's size after copying it into app-owned storage", async () => {
219 const file: SharePayload = {
220 shareType: "file",
221 value: "content://shared/report",
222 mimeType: "application/pdf",
223 };
224 const persistFile = vi.fn(async () => "file:///documents/report.pdf");
225 const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42));
226​
227 const result = await buildIncomingShareDraft({
228 id: "share-android-report",
229 createdAt: "2026-07-15T10:00:00.000Z",
230 payloads: [file],
231 resolvedPayloads: [],
232 fileReader: {
233 readBase64: async () => "unused",
234 persistFile,
235 readSize,
236 removeOwnedFile: async () => undefined,
237 },
238 });
239​
240 expect(result.attachments).toEqual([
241 {
242 id: "share-android-report:file:0",
243 type: "file",
244 name: "report",
245 mimeType: "application/pdf",
246 sizeBytes: 42,
247 fileUri: "file:///documents/report.pdf",
248 },
249 ]);
250 expect(readSize.mock.calls).toEqual([
251 ["content://shared/report"],
252 ["file:///documents/report.pdf"],
253 ]);
254 });
255​
256 it("rejects a shared file whose persisted copy measures empty and releases the copy", async () => {
257 const file: SharePayload = {
258 shareType: "file",
259 value: "content://shared/report",
260 mimeType: "application/pdf",
261 };
262 const persistedUri = "file:///documents/report.pdf";
263 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
264​
265 const result = await buildIncomingShareDraft({
266 id: "share-empty-copy",
267 createdAt: "2026-07-15T10:00:00.000Z",
268 payloads: [file],
269 resolvedPayloads: [],
270 fileReader: {
271 readBase64: async () => "unused",
272 persistFile: async () => persistedUri,
273 readSize: async (uri) => (uri.startsWith("content:") ? 42 : 0),
274 removeOwnedFile,
275 },
276 });
277​
278 expect(result.attachments).toEqual([]);
279 expect(result.warnings).toEqual(["'report' is empty or could not be read."]);
280 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
281 });
282​
283 it("keeps a no-copy file source that the returned attachment still owns", async () => {
284 const sourceUri = "file:///documents/report.pdf";
285 const removeOwnedFile = vi.fn(async () => undefined);
286​
287 const result = await buildIncomingShareDraft({
288 id: "share-no-copy",
289 createdAt: "2026-07-15T10:00:00.000Z",
290 payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }],
291 resolvedPayloads: [],
292 fileReader: {
293 readBase64: async () => "unused",
294 readSize: async () => 42,
295 removeOwnedFile,
296 },
297 });
298​
299 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: sourceUri });
300 expect(removeOwnedFile).not.toHaveBeenCalled();
301 });
302​
303 it("keeps a persisted copy and releases distinct temporary source URIs", async () => {
304 const payloadUri = "content://shared/report";
305 const resolvedUri = "file:///cache/report.pdf";
306 const persistedUri = "file:///documents/report.pdf";
307 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
308​
309 const result = await buildIncomingShareDraft({
310 id: "share-copy",
311 createdAt: "2026-07-15T10:00:00.000Z",
312 payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }],
313 resolvedPayloads: [
314 {
315 shareType: "file",
316 value: payloadUri,
317 mimeType: "application/pdf",
318 contentUri: resolvedUri,
319 contentType: "file",
320 contentMimeType: "application/pdf",
321 contentSize: 42,
322 originalName: "report.pdf",
323 },
324 ],
325 fileReader: {
326 readBase64: async () => "unused",
327 readSize: async () => 42,
328 persistFile: async () => persistedUri,
329 removeOwnedFile,
330 },
331 });
332​
333 expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: persistedUri });
334 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toEqual([resolvedUri, payloadUri]);
335 });
336​
337 it("uses current server support and limits when selecting a reserved share", () => {
338 const file = {
339 id: "file-1",
340 type: "file" as const,
341 name: "report.pdf",
342 mimeType: "application/pdf",
343 sizeBytes: 6 * 1024 * 1024,
344 fileUri: "file:///documents/report.pdf",
345 };
346​
347 expect(
348 selectIncomingShareAttachmentsForServer({ attachments: [file], serverConfig: null }),
349 ).toEqual({ status: "pending" });
350 expect(
351 selectIncomingShareAttachmentsForServer({
352 attachments: [file],
353 serverConfig: { environment: { capabilities: {} } },
354 }),
355 ).toMatchObject({ status: "ready", attachments: [] });
356 expect(
357 selectIncomingShareAttachmentsForServer({
358 attachments: [file],
359 serverConfig: {
360 environment: {
361 capabilities: {
362 fileAttachments: { maxUploadBytes: 5 * 1024 * 1024 },
363 },
364 },
365 },
366 }),
367 ).toMatchObject({ status: "ready", attachments: [] });
368 expect(
369 selectIncomingShareAttachmentsForServer({
370 attachments: [file],
371 serverConfig: {
372 environment: {
373 capabilities: {
374 fileAttachments: { maxUploadBytes: 10 * 1024 * 1024 },
375 },
376 },
377 },
378 }),
379 ).toMatchObject({ status: "ready", attachments: [file] });
380 });
381​
382 it("releases every temporary file when a share exceeds the attachment limit", async () => {
383 const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
384 shareType: "image" as const,
385 value: `file:///shared/${index}.png`,
386 mimeType: "image/png",
387 }));
388 const removeOwnedFile = vi.fn(() => Promise.resolve());
389 const readBase64 = vi.fn(async () => "YWJj");
390​
391 const result = await buildIncomingShareDraft({
392 id: "share-3",
393 createdAt: "2026-07-15T10:00:00.000Z",
394 payloads,
395 resolvedPayloads: [],
396 fileReader: { readBase64, removeOwnedFile },
397 });
398​
399 expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
400 expect(result.warnings).toEqual([
401 `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`,
402 ]);
403 expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS);
404 expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length);
405 });
406​
407 it("maps duplicate image payloads to distinct resolved files", async () => {
408 const duplicate: SharePayload = {
409 shareType: "image",
410 value: "content://shared/screenshot",
411 mimeType: "image/png",
412 };
413 const resolvedPayloads: ResolvedSharePayload[] = [
414 {
415 ...duplicate,
416 contentUri: "file:///cache/first.png",
417 contentType: "image",
418 contentMimeType: "image/png",
419 contentSize: 3,
420 originalName: "first.png",
421 },
422 {
423 ...duplicate,
424 contentUri: "file:///cache/second.png",
425 contentType: "image",
426 contentMimeType: "image/png",
427 contentSize: 3,
428 originalName: "second.png",
429 },
430 ];
431 const readBase64 = vi.fn(async (uri: string) =>
432 uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k",
433 );
434 const removeOwnedFile = vi.fn(async () => undefined);
435​
436 const result = await buildIncomingShareDraft({
437 id: "share-duplicates",
438 createdAt: "2026-07-16T08:00:00.000Z",
439 payloads: [duplicate, duplicate],
440 resolvedPayloads,
441 fileReader: { readBase64, removeOwnedFile },
442 });
443​
444 expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([
445 "file:///cache/first.png",
446 "file:///cache/second.png",
447 ]);
448 expect(result.attachments.map((attachment) => attachment.name)).toEqual([
449 "first.png",
450 "second.png",
451 ]);
452 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png");
453 expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png");
454 });
455​
456 it("keeps imported content when temporary-file cleanup fails", async () => {
457 const image: SharePayload = {
458 shareType: "image",
459 value: "file:///shared/screenshot.png",
460 mimeType: "image/png",
461 };
462​
463 const result = await buildIncomingShareDraft({
464 id: "share-cleanup-failure",
465 createdAt: "2026-07-16T08:00:00.000Z",
466 payloads: [image],
467 resolvedPayloads: [],
468 fileReader: {
469 readBase64: async () => "YWJj",
470 removeOwnedFile: async () => {
471 throw new Error("file is busy");
472 },
473 },
474 });
475​
476 expect(result.attachments).toHaveLength(1);
477 expect(result.warnings).toEqual([]);
478 });
479​
480 it("releases a persisted copy when a later step fails to read it", async () => {
481 const file: SharePayload = {
482 shareType: "file",
483 value: "content://shared/report",
484 mimeType: "application/pdf",
485 };
486 const persistedUri = "file:///documents/t3-composer-attachments/report.pdf";
487 const removeOwnedFile = vi.fn(async (_uri: string) => undefined);
488​
489 const result = await buildIncomingShareDraft({
490 id: "share-persist-leak",
491 createdAt: "2026-07-16T08:00:00.000Z",
492 payloads: [file],
493 resolvedPayloads: [],
494 fileReader: {
495 readBase64: async () => "unused",
496 persistFile: async () => persistedUri,
497 readSize: async (uri) => {
498 if (uri === persistedUri) {
499 throw new Error("read failed");
500 }
501 return null;
502 },
503 removeOwnedFile,
504 },
505 });
506​
507 expect(result.attachments).toEqual([]);
508 expect(result.warnings).toEqual(["read failed"]);
509 expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri);
510 });
511});
512​
513describe("share cleanup ownership", () => {
514 const ownedRoots = [
515 "file:///var/mobile/Containers/Data/Application/APP/Documents/",
516 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
517 ];
518​
519 it("treats /private/var and /var as the same iOS location", () => {
520 expect(
521 isShareFileUriUnderOwnedRoots(
522 "file:///private/var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf",
523 ownedRoots,
524 ),
525 ).toBe(true);
526 expect(
527 isShareFileUriUnderOwnedRoots(
528 "file:///var/mobile/Containers/Data/Application/APP/Documents/t3-composer-attachments/a.pdf",
529 ["file:///private/var/mobile/Containers/Data/Application/APP/Documents/"],
530 ),
531 ).toBe(true);
532 });
533​
534 it("refuses to delete a sender-owned open-in-place document", () => {
535 expect(
536 isShareFileUriUnderOwnedRoots(
537 "file:///private/var/mobile/Containers/Shared/FileProvider/OTHER/File%20Provider%20Storage/taxes.pdf",
538 ownedRoots,
539 ),
540 ).toBe(false);
541 });
542​
543 it("refuses traversal segments that escape an owned root", () => {
544 expect(
545 isShareFileUriUnderOwnedRoots(
546 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/..%2F..%2FsenderDoc.pdf",
547 ownedRoots,
548 ),
549 ).toBe(false);
550 expect(
551 isShareFileUriUnderOwnedRoots(
552 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/../senderDoc.pdf",
553 ownedRoots,
554 ),
555 ).toBe(false);
556 expect(
557 isShareFileUriUnderOwnedRoots(
558 "file:///var/mobile/Containers/Shared/AppGroup/GROUP/%2e%2e/senderDoc.pdf",
559 ownedRoots,
560 ),
561 ).toBe(false);
562 });
563​
564 it("refuses non-file URIs and the owned root itself", () => {
565 expect(isShareFileUriUnderOwnedRoots("content://shared/report", ownedRoots)).toBe(false);
566 expect(
567 isShareFileUriUnderOwnedRoots(
568 "file:///var/mobile/Containers/Shared/AppGroup/GROUP",
569 ownedRoots,
570 ),
571 ).toBe(false);
572 });
573});