- 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.
192 lines
6.4 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}); |