812 lines · 26.3 KB · TypeScript
RawDownload
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";import type { ImagePickerAsset } from "expo-image-picker"; const mocks = vi.hoisted(() => ({  documentUri: "file:///documents",  pickFile: vi.fn(),  pickMedia: vi.fn(),  copy: vi.fn(),  delete: vi.fn(),  open: vi.fn(),  size: vi.fn(),  readBase64: vi.fn(),})); vi.mock("expo-file-system", () => {  class Directory {    readonly uri: string;     constructor(root: string | { readonly uri: string }, name: string) {      this.uri = `${typeof root === "string" ? root : root.uri}/${name}`;    }     create(): void {}  }   class File {    readonly uri: string;     constructor(source: string | Directory, name?: string) {      this.uri = source instanceof Directory ? `${source.uri}/${name}` : source;    }     get exists(): boolean {      return true;    }     get size(): number | null {      return mocks.size(this.uri) ?? null;    }     get name(): string {      return this.uri.split("/").at(-1) ?? "";    }     get type(): string {      return "video/quicktime";    }     create(): void {}     open(mode: string) {      return mocks.open(this.uri, mode);    }     async copy(destination: File): Promise<void> {      mocks.copy(this.uri, destination.uri);    }     async base64(): Promise<string> {      return mocks.readBase64(this.uri);    }     delete(): void {      mocks.delete(this.uri);    }  }   return {    Directory,    File,    FileMode: { ReadOnly: "r", WriteOnly: "w" },    Paths: {      get document() {        return { uri: mocks.documentUri };      },    },  };}); vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia }));vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile }));vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); import {  persistComposerAttachmentFile,  pickComposerFiles,  pickComposerImages,  pickComposerMedia,  removePersistedComposerAttachmentFile,} from "./composerImages";import { isForegroundHandoffActive } from "./foreground-handoff";import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; describe("composer file attachments", () => {  beforeEach(() => {    mocks.documentUri = "file:///documents";    mocks.pickFile.mockReset();    mocks.pickMedia.mockReset();    mocks.copy.mockReset();    mocks.delete.mockReset();    mocks.open.mockReset();    mocks.size.mockReset();    mocks.readBase64.mockReset();    mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));  });   describe("photo library image conversion", () => {    const jpeg = "/9j/2Q==";    const photo: ImagePickerAsset = {      uri: "file:///picker/photo.heic",      type: "image",      fileName: "photo.HEIC",      mimeType: "image/heic",      fileSize: 20 * 1024 * 1024,      base64: jpeg,      width: 1,      height: 1,    };     it.each(["image/heic", "image/heif", undefined])(      "attaches the native JPEG conversion with matching metadata when the source MIME is %s",      async (mimeType) => {        mocks.pickMedia.mockResolvedValue({          canceled: false,          assets: [{ ...photo, mimeType }],        });         const result = await pickComposerImages({ existingCount: 0 });         expect(result).toEqual({          images: [            {              id: "attachment-id",              type: "image",              name: "photo.jpg",              mimeType: "image/jpeg",              sizeBytes: 4,              dataUrl: `data:image/jpeg;base64,${jpeg}`,              previewUri: `data:image/jpeg;base64,${jpeg}`,            },          ],          error: null,        });      },    );     it.each([      { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },      { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },      { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },    ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => {      const name = `photo.${original.extension}`;      mocks.pickMedia.mockResolvedValue({        canceled: false,        assets: [{ ...photo, fileName: name, mimeType: original.mimeType }],      });      mocks.readBase64.mockResolvedValue(original.base64);       const result = await pickComposerImages({ existingCount: 0 });       expect(result.error).toBeNull();      expect(result.images).toEqual([        expect.objectContaining({          name,          mimeType: original.mimeType,          dataUrl: `data:${original.mimeType};base64,${original.base64}`,          sizeBytes: Buffer.from(original.base64, "base64").byteLength,        }),      ]);    });     it("checks the converted JPEG size even when the HEIC source was smaller", async () => {      const oversized =        jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4);      mocks.pickMedia.mockResolvedValue({        canceled: false,        assets: [{ ...photo, fileSize: 42, base64: oversized }],      });       await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({        images: [],        error: "'photo.HEIC' exceeds the 10 MB attachment limit.",      });    });     it("does not relabel unconverted HEIC bytes as JPEG", async () => {      mocks.pickMedia.mockResolvedValue({        canceled: false,        assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }],      });       const result = await pickComposerImages({ existingCount: 0 });       expect(result.images).toEqual([]);      expect(result.error).toContain("not a supported image type");    });     it("retains a converted photo when another original cannot be read", async () => {      mocks.pickMedia.mockResolvedValue({        canceled: false,        assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],      });      mocks.readBase64.mockRejectedValue(new Error("missing file"));       const result = await pickComposerImages({ existingCount: 0 });       expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);      expect(result.error).toBe("Failed to read 'missing.gif'.");    });  });   describe("photo library videos", () => {    const image: ImagePickerAsset = {      uri: "file:///picker/photo.png",      type: "image",      fileName: "photo.png",      mimeType: "image/png",      fileSize: 3,      base64: "YWJj",      width: 1,      height: 1,    };    const video: ImagePickerAsset = {      uri: "file:///picker/clip.mov",      type: "video",      fileName: "clip.mov",      mimeType: "video/quicktime",      fileSize: 20 * 1024 * 1024,      base64: null,      width: 1920,      height: 1080,    };     it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => {      mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });      mocks.size.mockReturnValue(video.fileSize);       const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 });       expect(mocks.pickMedia).toHaveBeenCalledWith(        expect.objectContaining({          mediaTypes: ["images", "videos"],          shouldDownloadFromNetwork: true,        }),      );      expect(result).toEqual({        attachments: [          expect.objectContaining({ type: "image", dataUrl: "data:image/png;base64,YWJj" }),          {            id: "attachment-id",            type: "file",            name: "clip.mov",            mimeType: "video/quicktime",            sizeBytes: video.fileSize,            fileUri: "file:///documents/t3-composer-attachments/attachment-id-clip.mov",          },        ],        error: null,      });      expect(mocks.copy).toHaveBeenCalledWith(        video.uri,        "file:///documents/t3-composer-attachments/attachment-id-clip.mov",      );      expect(mocks.delete).not.toHaveBeenCalled();    });     it("keeps image-only destinations on the image picker path", async () => {      mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image] });       const result = await pickComposerImages({ existingCount: 0 });       expect(mocks.pickMedia).toHaveBeenCalledWith(        expect.objectContaining({ mediaTypes: ["images"] }),      );      expect(result.images).toEqual([        expect.objectContaining({ type: "image", name: "photo.png" }),      ]);      expect(result.error).toBeNull();      expect(mocks.copy).not.toHaveBeenCalled();    });     it("does not persist videos when the destination lacks file support", async () => {      mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] });       const result = await pickComposerMedia({ existingCount: 0 });       expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);      expect(result.error).toBe("Video attachments are unavailable here.");      expect(mocks.copy).not.toHaveBeenCalled();    });     it("uses local video metadata when the picker omits its name, MIME type, or size", async () => {      mocks.pickMedia.mockResolvedValue({        canceled: false,        assets: [{ ...video, fileName: null, mimeType: undefined, fileSize: undefined }],      });       const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 });       expect(result.error).toBeNull();      expect(result.attachments).toEqual([        expect.objectContaining({          type: "file",          name: "clip.mov",          mimeType: "video/quicktime",          sizeBytes: 42,        }),      ]);    });     it.each([      {        reason: "picker size exceeds the server limit",        reported: 2 * 1024 * 1024,        stored: 42,        limit: 1024 * 1024,        error: "'clip.mov' exceeds the 1 MB attachment limit.",      },      {        reason: "actual size exceeds the server limit",        reported: 42,        stored: 2 * 1024 * 1024,        limit: 1024 * 1024,        error: "'clip.mov' exceeds the 1 MB attachment limit.",      },      {        reason: "stored copy is empty",        reported: 42,        stored: 0,        limit: 1024 * 1024,        error: "'clip.mov' is empty or could not be read.",      },      {        reason: "server advertises more than the contract limit",        reported: 51 * 1024 * 1024,        stored: 42,        limit: 80 * 1024 * 1024,        error: "'clip.mov' exceeds the 50 MB attachment limit.",      },    ])(      "rejects a video when $reason while retaining the selected photo",      async ({ reported, stored, limit, error }) => {        mocks.pickMedia.mockResolvedValue({          canceled: false,          assets: [{ ...video, fileSize: reported }, image],        });        mocks.size.mockReturnValue(stored);         const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit });         expect(result).toEqual({          attachments: [expect.objectContaining({ type: "image" })],          error,        });        if (stored === 0) {          expect(mocks.delete).toHaveBeenCalledWith(            "file:///documents/t3-composer-attachments/attachment-id-clip.mov",          );        }      },    );     it("applies the remaining attachment slots to photos and videos together", async () => {      mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });       const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 });       expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);      expect(result.error).toBe("You can attach up to 8 attachments per message.");      expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));      expect(mocks.copy).not.toHaveBeenCalled();    });     it("reports a native video retrieval error and ends the foreground handoff", async () => {      mocks.pickMedia.mockRejectedValue(new Error("Could not download video from iCloud."));       await expect(pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 })).resolves.toEqual({        attachments: [],        error: "Could not download video from iCloud.",      });      expect(isForegroundHandoffActive()).toBe(false);    });  });   it("copies picked files into app-owned storage without loading their contents", async () => {    mocks.pickFile.mockResolvedValue({      canceled: false,      assets: [        {          uri: "file:///downloads/report.pdf",          name: "report.pdf",          mimeType: "application/pdf",          size: 42,        },      ],    });     await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({      files: [        {          id: "attachment-id",          type: "file",          name: "report.pdf",          mimeType: "application/pdf",          sizeBytes: 42,          fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf",        },      ],      error: null,    });    expect(mocks.copy).toHaveBeenCalledWith(      "file:///downloads/report.pdf",      "file:///documents/t3-composer-attachments/attachment-id-report.pdf",    );  });   it("preserves Android picker metadata instead of using the content URI document id", async () => {    const uri = "content://com.android.providers.media.documents/document/video%3A18";    mocks.pickFile.mockResolvedValue({      canceled: false,      assets: [        {          uri,          name: "preview-h264.mp4",          mimeType: "video/mp4",          size: 620_992,          lastModified: 0,        },      ],    });    mocks.size.mockReturnValue(620_992);     await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({      files: [        {          id: "attachment-id",          type: "file",          name: "preview-h264.mp4",          mimeType: "video/mp4",          sizeBytes: 620_992,          fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",        },      ],      error: null,    });    expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true });    expect(mocks.copy).toHaveBeenCalledWith(      uri,      "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4",    );    expect(mocks.delete).not.toHaveBeenCalled();  });   it("persists provider selections that require a readable cache copy", async () => {    const providerUri = "content://cloud-provider/documents/clip";    const cachedUri = "file:///cache/DocumentPicker/clip.mp4";    mocks.pickFile.mockImplementation(async (options) => ({      canceled: false,      assets: [        {          uri: options.copyToCacheDirectory ? cachedUri : providerUri,          name: "Cloud recording.mp4",          mimeType: "video/mp4",          size: 42,          lastModified: 0,        },      ],    }));    mocks.copy.mockImplementation((uri: string) => {      if (uri === providerUri) throw new Error("The provider URI is not directly readable.");    });     const result = await pickComposerFiles({ existingCount: 0 });     expect(result.error).toBeNull();    expect(result.files).toEqual([      expect.objectContaining({        name: "Cloud recording.mp4",        fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4",      }),    ]);    expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri);  });   it("ends the foreground handoff when the picker is canceled without copying files", async () => {    mocks.pickFile.mockImplementation(async () => {      expect(isForegroundHandoffActive()).toBe(true);      return { canceled: true, assets: null };    });     await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({      files: [],      error: null,    });     expect(isForegroundHandoffActive()).toBe(false);    expect(mocks.copy).not.toHaveBeenCalled();    expect(mocks.open).not.toHaveBeenCalled();  });

Lines 1–500 of 812.