1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 retain: vi.fn(),
5 share: vi.fn(),
6 exists: vi.fn(),
7}));
8
9vi.mock("./composerAttachmentPreviewRetention", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
13vi.mock("expo-file-system", () => ({
14 File: class {
15 constructor(readonly uri: string) {}
16 get exists(): boolean {
17 return mocks.exists(this.uri);
18 }
19 },
20 Paths: {
21 document: {
22 uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/",
23 },
24 },
25}));
26
27import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
28
29const attachment = {
30 type: "file" as const,
31 id: "draft-video",
32 name: "clip.mov",
33 mimeType: "video/quicktime",
34 sizeBytes: 12,
35 fileUri:
36 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov",
37};
38
39beforeEach(() => {
40 mocks.retain.mockReset();
41 mocks.share.mockReset();
42 mocks.exists.mockReset();
43 mocks.retain.mockImplementation(() => vi.fn());
44 mocks.exists.mockReturnValue(true);
45 mocks.share.mockResolvedValue(undefined);
46});
47
48describe("loadLocalAttachmentPreview", () => {
49 it("keeps an image MIME type when its filename has a video extension", async () => {
50 const image = {
51 ...attachment,
52 type: "image" as const,
53 name: "photo.mov",
54 mimeType: "image/png",
55 previewUri: attachment.fileUri,
56 };
57 const preview = await loadLocalAttachmentPreview(image, new AbortController().signal);
58 await preview!.share(new AbortController().signal);
59 expect(mocks.share).toHaveBeenCalledWith(
60 expect.objectContaining({
61 attachment: { name: "photo.mov", mimeType: "image/png" },
62 }),
63 );
64 preview!.dispose();
65 });
66
67 it("retains and shares a PDF with its original filename and type", async () => {
68 const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };
69 const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);
70 await preview!.share(new AbortController().signal);
71 expect(mocks.share).toHaveBeenCalledWith(
72 expect.objectContaining({
73 attachment: { name: "report.pdf", mimeType: "application/pdf" },
74 }),
75 );
76 expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
77 preview!.dispose();
78 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
79 });
80 it("resolves the current iOS container and releases its playback lease once", async () => {
81 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
82 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
83 expect(mocks.retain).toHaveBeenCalledWith(attachment);
84 const release = mocks.retain.mock.results[0]!.value;
85 expect(release).not.toHaveBeenCalled();
86 preview?.dispose();
87 preview?.dispose();
88 expect(release).toHaveBeenCalledTimes(1);
89 });
90
91 it.each([undefined, "share-button"])(
92 "keeps a separate share lease after playback closes (source: %s)",
93 async (sourceIdentifier) => {
94 const shared = Promise.withResolvers<void>();
95 mocks.share.mockReturnValue(shared.promise);
96 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
97 const share = preview!.share(new AbortController().signal, sourceIdentifier);
98 expect(mocks.retain).toHaveBeenCalledTimes(2);
99 const releasePlayback = mocks.retain.mock.results[0]!.value;
100 const releaseShare = mocks.retain.mock.results[1]!.value;
101 preview!.dispose();
102 expect(releasePlayback).toHaveBeenCalledTimes(1);
103 expect(releaseShare).not.toHaveBeenCalled();
104 shared.resolve();
105 await share;
106 expect(releaseShare).toHaveBeenCalledTimes(1);
107 },
108 );
109
110 it("releases a failed share while keeping playback retained", async () => {
111 mocks.share.mockRejectedValue(new Error("Sharing unavailable"));
112 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
113 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(
114 "Sharing unavailable",
115 );
116 expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1);
117 expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
118 preview!.dispose();
119 });
120
121 it("releases a load canceled during native module loading", async () => {
122 const controller = new AbortController();
123 const loading = loadLocalAttachmentPreview(attachment, controller.signal);
124 controller.abort();
125 await expect(loading).resolves.toBeNull();
126 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
127 expect(mocks.exists).not.toHaveBeenCalled();
128 });
129
130 it("reports missing files and releases their lease", async () => {
131 mocks.exists.mockReturnValue(false);
132 await expect(
133 loadLocalAttachmentPreview(attachment, new AbortController().signal),
134 ).rejects.toThrow("This attachment is no longer available. Attach the file again.");
135 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
136 });
137
138 it("does not start sharing a disposed preview", async () => {
139 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
140 preview!.dispose();
141 await preview!.share(new AbortController().signal);
142 expect(mocks.share).not.toHaveBeenCalled();
143 expect(mocks.retain).toHaveBeenCalledTimes(1);
144 });
145});