/** * Behavioural tests for `useUploadBlobUrl`. * * The platform branch is the point of most of these: `URL.createObjectURL` is * a browser API that React Native does not implement usably, so web gets a * blob URL and native gets a data URI. `react-native` is mocked down to a * mutable `Platform` so one file can drive both. */ import { act, renderHook, waitFor } from "@testing-library/react-native"; import { Platform } from "react-native"; import { apiFetch } from "../../utils/api-fetch"; import { useUploadBlobUrl } from "../useUploadBlobUrl"; // Only `Platform` is reached from the hook. A full react-native mock would be // a much bigger surface to keep true than this needs. jest.mock("react-native", () => ({ Platform: { OS: "ios" } })); jest.mock("../../utils/api-fetch", () => ({ apiFetch: jest.fn(), })); const apiFetchMock = apiFetch as jest.MockedFunction; const mutablePlatform = Platform as { OS: string }; const originalCreateObjectURL = globalThis.URL.createObjectURL; const originalRevokeObjectURL = globalThis.URL.revokeObjectURL; let urlCounter = 0; const revokedUrls: string[] = []; /** A PNG-flavoured response, headers included — the data URI carries its type. */ function imageResponse(bytes: number[], contentType = "image/png"): Response { return new Response(Uint8Array.from(bytes), { status: 200, headers: { "Content-Type": contentType }, }); } beforeEach(() => { apiFetchMock.mockReset(); mutablePlatform.OS = "ios"; urlCounter = 0; revokedUrls.length = 0; globalThis.URL.createObjectURL = jest.fn(() => `blob:test/${++urlCounter}`); globalThis.URL.revokeObjectURL = jest.fn((u: string) => { revokedUrls.push(u); }); }); afterAll(() => { globalThis.URL.createObjectURL = originalCreateObjectURL; globalThis.URL.revokeObjectURL = originalRevokeObjectURL; }); describe("useUploadBlobUrl", () => { it("returns null while idle", () => { const { result } = renderHook(() => useUploadBlobUrl(null)); expect(result.current.uri).toBeNull(); expect(result.current.errored).toBe(false); }); it("builds a data URI on native, carrying the response's content type", async () => { // "ok" — short enough to check the base64 by eye. apiFetchMock.mockResolvedValueOnce(imageResponse([0x6f, 0x6b], "image/webp")); const { result } = renderHook(() => useUploadBlobUrl("abc")); await waitFor(() => { expect(result.current.uri).toBe("data:image/webp;base64,b2s="); }); expect(result.current.errored).toBe(false); // Never `createObjectURL` on native: that is the whole bug this replaced. expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled(); }); it("encodes every remainder length correctly", async () => { // Base64 pads by input length mod 3, and the two padded cases take // different branches. Byte values chosen so a bit-shift slip shows up as a // wrong character rather than a coincidentally-right one. const cases: [number[], string][] = [ [[0xff], "/w=="], [[0xff, 0xee], "/+4="], [[0xff, 0xee, 0xdd], "/+7d"], [[0x00, 0x00, 0x00, 0x01], "AAAAAQ=="], ]; for (const [bytes, expected] of cases) { apiFetchMock.mockResolvedValueOnce(imageResponse(bytes)); const { result, unmount } = renderHook(() => useUploadBlobUrl(`id-${expected}`)); await waitFor(() => { expect(result.current.uri).toBe(`data:image/png;base64,${expected}`); }); act(() => { unmount(); }); } }); it("keeps the blob URL on web, and revokes it on unmount", async () => { mutablePlatform.OS = "web"; apiFetchMock.mockResolvedValueOnce(new Response(new Blob(["ok"]), { status: 200 })); const { result, unmount } = renderHook(() => useUploadBlobUrl("abc")); await waitFor(() => { expect(result.current.uri).toMatch(/^blob:test\//); }); const uri = result.current.uri; if (uri === null) { throw new Error("uri not set"); } act(() => { unmount(); }); // The revoke matters on web and only on web — a data URI is a string with // nothing to release. expect(revokedUrls).toContain(uri); }); it("marks errored when the fetch fails", async () => { apiFetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); const { result } = renderHook(() => useUploadBlobUrl("abc")); await waitFor(() => { expect(result.current.errored).toBe(true); }); expect(result.current.uri).toBeNull(); }); describe("with a consumer-supplied resolver", () => { it("uses it instead of the transport", async () => { const resolve = jest.fn().mockResolvedValue("data:image/png;base64,AAAA"); const { result } = renderHook(() => useUploadBlobUrl("abc", { resolve })); await waitFor(() => { expect(result.current.uri).toBe("data:image/png;base64,AAAA"); }); expect(resolve).toHaveBeenCalledWith("abc", expect.any(AbortSignal)); // The reason the seam exists: a consumer whose uploads are not behind a // server must not have one asked. expect(apiFetchMock).not.toHaveBeenCalled(); }); it("reports an unresolvable id as errored so the fallback is drawn", async () => { const resolve = jest.fn().mockResolvedValue(null); const { result } = renderHook(() => useUploadBlobUrl("gone", { resolve })); await waitFor(() => { expect(result.current.errored).toBe(true); }); expect(result.current.uri).toBeNull(); }); it("reports a throwing resolver as errored rather than crashing the tree", async () => { const resolve = jest.fn().mockRejectedValue(new Error("database is closed")); const { result } = renderHook(() => useUploadBlobUrl("abc", { resolve })); await waitFor(() => { expect(result.current.errored).toBe(true); }); }); it("does not re-resolve when only the resolver's identity changes", async () => { // The hazard this guards: an inline arrow at the call site is a new // function every render, and a resolver in the effect's deps would turn // that into an unbounded refetch loop. What identifies the resource is // the id. const first = jest.fn().mockResolvedValue("data:image/png;base64,AAAA"); const { result, rerender } = renderHook( ({ resolve }: { resolve: jest.Mock }) => useUploadBlobUrl("abc", { resolve }), { initialProps: { resolve: first } } ); await waitFor(() => { expect(result.current.uri).not.toBeNull(); }); const second = jest.fn().mockResolvedValue("data:image/png;base64,BBBB"); act(() => { rerender({ resolve: second }); rerender({ resolve: jest.fn() }); }); expect(first).toHaveBeenCalledTimes(1); expect(second).not.toHaveBeenCalled(); }); it("still accepts a bare path as the second argument", async () => { // The published signature was `(uploadId, uploadsPath?)`; other // consumers are on it. apiFetchMock.mockResolvedValueOnce(imageResponse([0x6f, 0x6b])); const { result } = renderHook(() => useUploadBlobUrl("abc", "/attachments")); await waitFor(() => { expect(result.current.uri).not.toBeNull(); }); expect(apiFetchMock).toHaveBeenCalledWith("/attachments/abc", expect.anything()); }); }); });