Files
maarcadetweet/crates/tauri-app/src/lib/api/client.test.ts
T
tomdebone b912132a05 tauri-app: 8a review fixes
- fetchBlob cache keyed by (did, cid), not just cid.
  Security: future per-DID access control on getBlob would
  otherwise leak the first responder's bytes to subsequent
  viewers.
- EmbedImage: pass did to releaseBlob, release previous cid
  on cid change (no leaked URLs).
- ComposeBox: releaseBlob called with both did and cid.
- pds-server: rename test
  get_blob_after_upload_with_different_did ->
  get_blob_returns_404_for_cross_did_cid_lookup. The
  docstring was misleading — the test only verifies the
  (did,cid) PK on the PDS row, not auth. The renamed name
  matches what the test actually checks.
- vitest: update releaseBlob call sites to the new
  (did, cid) signature.
2026-07-06 18:53:09 +02:00

192 lines
6.4 KiB
TypeScript

// 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,
});
});
});