// Unit tests for the image-blob fetch pipeline in `client.ts`. // We mock `@tauri-apps/api/core` so we don't need a running Tauri // shell for the test, and we mock `URL.createObjectURL` / // `URL.revokeObjectURL` so the test doesn't depend on a browser // implementation (vitest's jsdom doesn't always wire these up). // // The tests cover: // * the happy path — a successful `fetch_blob` invoke produces an // object URL and the cache is populated so a second call is a no-op; // * the cached path — a second `fetchBlob` for the same CID does not // call `invoke` again; // * the empty-blob error path — an empty payload throws; // * `releaseBlob` / `clearBlobCache` keep the cache honest. // // Run with: // npm test // (or `npx vitest run src/lib/api/client.test.ts`) import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const invokeMock = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invokeMock(...args), })); // Capture object URLs so the test can assert the cache is wired up. // `createObjectURL` returns a fresh URL per call; tests should rely // on the spy to know what URL was returned. const createdUrls: string[] = []; let _urlCounter = 0; beforeEach(() => { invokeMock.mockReset(); createdUrls.length = 0; _urlCounter = 0; // `URL.createObjectURL` exists in jsdom (vitest default) but // returns the empty string — stub it so the test sees real-ish // URLs and we can spot leaks. vi.stubGlobal( "URL", class { static createObjectURL(_blob: Blob): string { const u = `blob:mock/${++_urlCounter}`; createdUrls.push(u); return u; } static revokeObjectURL(url: string): void { const i = createdUrls.indexOf(url); if (i >= 0) createdUrls.splice(i, 1); } }, ); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("fetchBlob", () => { it("invokes the fetch_blob Tauri command and returns an object URL", async () => { const { fetchBlob, clearBlobCache } = await import("./client"); clearBlobCache(); const cid = "bafyreifake"; const fakeBytes = new Uint8Array([1, 2, 3, 4]); invokeMock.mockResolvedValueOnce(Array.from(fakeBytes)); const url = await fetchBlob("did:plc:alice", cid); expect(url).toMatch(/^blob:mock\/\d+$/); expect(createdUrls).toContain(url); expect(invokeMock).toHaveBeenCalledTimes(1); expect(invokeMock).toHaveBeenCalledWith("fetch_blob", { did: "did:plc:alice", cid, }); }); it("caches the object URL — second call for the same cid is a no-op", async () => { const { fetchBlob, clearBlobCache } = await import("./client"); clearBlobCache(); const cid = "bafyreicached"; invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([9, 9, 9]))); const first = await fetchBlob("did:plc:bob", cid); const second = await fetchBlob("did:plc:bob", cid); expect(second).toBe(first); // Only the first call should have round-tripped through the // Tauri shell; the cache hit short-circuits everything else. expect(invokeMock).toHaveBeenCalledTimes(1); }); it("rejects on an empty payload from the Tauri shell", async () => { const { fetchBlob, clearBlobCache } = await import("./client"); clearBlobCache(); invokeMock.mockResolvedValueOnce([]); await expect( fetchBlob("did:plc:carol", "bafyreifake"), ).rejects.toThrow(/empty blob/); }); it("propagates a Tauri-side error verbatim", async () => { const { fetchBlob, clearBlobCache } = await import("./client"); clearBlobCache(); invokeMock.mockRejectedValueOnce(new Error("sync.getBlob failed: 400")); await expect( fetchBlob("did:plc:dave", "bafyreifake"), ).rejects.toThrow(/sync\.getBlob failed: 400/); }); }); describe("releaseBlob / clearBlobCache", () => { it("drops the cached URL on releaseBlob", async () => { const { fetchBlob, releaseBlob, clearBlobCache } = await import("./client"); clearBlobCache(); invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1]))); const cid = "bafyreirel"; const url = await fetchBlob("did:plc:e", cid); expect(createdUrls).toContain(url); releaseBlob("did:plc:e", cid); expect(createdUrls).not.toContain(url); }); it("clearBlobCache evicts every cached URL", async () => { const { fetchBlob, clearBlobCache } = await import("./client"); clearBlobCache(); invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([1]))); invokeMock.mockResolvedValueOnce(Array.from(new Uint8Array([2]))); await fetchBlob("did:plc:f", "bafyreia"); await fetchBlob("did:plc:f", "bafyreib"); expect(createdUrls).toHaveLength(2); clearBlobCache(); expect(createdUrls).toHaveLength(0); }); }); describe("makeImagesEmbed", () => { it("wraps a blob reference in an app.bsky.embed.images embed", async () => { const { makeImagesEmbed } = await import("./client"); const blob = { cid: "bafyreicid", mimeType: "image/png", size: 42 }; const e = makeImagesEmbed(blob) as any; expect(e.$type).toBe("app.bsky.embed.images"); expect(e.images).toHaveLength(1); expect(e.images[0].image.$type).toBe("blob"); expect(e.images[0].image.ref.$link).toBe("bafyreicid"); expect(e.images[0].image.mimeType).toBe("image/png"); expect(e.images[0].image.size).toBe(42); }); }); describe("pickAndUploadImage", () => { it("returns null when the user cancels the picker", async () => { const { pickAndUploadImage } = await import("./client"); invokeMock.mockResolvedValueOnce(null); const r = await pickAndUploadImage(); expect(r).toBeNull(); // `invoke` is called with the command name and an empty // argument bag (the Tauri runtime serialises the JS-side // options, and `pick_and_upload_image` takes none). expect(invokeMock).toHaveBeenCalledTimes(1); expect(invokeMock.mock.calls[0][0]).toBe("pick_and_upload_image"); }); it("returns the parsed {cid, mimeType, size} on success", async () => { const { pickAndUploadImage } = await import("./client"); invokeMock.mockResolvedValueOnce({ blob: { $type: "blob", ref: { $link: "bafyblob" }, mimeType: "image/jpeg", size: 1234, }, }); const r = await pickAndUploadImage(); expect(r).toEqual({ cid: "bafyblob", mimeType: "image/jpeg", size: 1234, }); }); });