/** * Tests for `ApiClient`. * * `ApiClient` is the HTTP layer that every other module in quack calls to * reach the Ente server. It handles: * * - Base URL resolution. Production uses `https://api.ente.io` for the * API, `https://files.ente.io` for file downloads, and * `https://thumbnails.ente.io` for thumbnail downloads. Self-hosted * deployments override the API origin via the constructor or via the * `ENTE_API_ENDPOINT` environment variable. When a custom API origin * is set, file and thumbnail downloads route through that same origin * at `/files/download/` and `/files/preview/` instead of the * dedicated CDN hosts. * * - Required headers. Every request carries `X-Client-Package` * (`berlin.sneak.quack`). Authenticated requests also carry * `X-Auth-Token` with the token recovered by `unwrapAuth`. * * - JSON serialization. `getJSON` and `postJSON` handle Accept / * Content-Type headers and JSON parsing. * * - Error mapping. Non-2xx responses become `ApiError` instances that * expose `.status`, `.code` (from the server's JSON error body, if * present), `.requestID` (from the `x-request-id` response header), * and `.body` (the raw parsed body). * * - Streaming downloads. `getFileStream` and `getThumbnailStream` * return a `ReadableStream` from the appropriate CDN * (or the self-hosted fallback path). * * All tests inject a fake `fetch` via the constructor so nothing touches * the network. The fake records every call for assertion. */ import { describe, expect, it } from "vitest"; import { ApiClient, ApiError } from "../../src/api/client.js"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- /** * A minimal fake Response that satisfies the subset of the Response API * that ApiClient uses. Keeps tests short. */ const jsonResponse = ( body: unknown, status = 200, headers: Record = {}, ): Response => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers }, }); const textResponse = ( body: string, status = 200, headers: Record = {}, ): Response => new Response(body, { status, headers: { "content-type": "text/plain", ...headers }, }); const streamResponse = ( body: Uint8Array, status = 200, headers: Record = {}, ): Response => new Response(body, { status, headers }); /** * Build a recording fetch. Returns the fake function and a list of * captured `{ url, init }` pairs. Each call pops the next canned * response; if the list runs out, the call rejects. */ const recordingFetch = ( ...responses: Response[] ): { fetch: typeof globalThis.fetch; calls: { url: string; init: RequestInit | undefined }[]; } => { const calls: { url: string; init: RequestInit | undefined }[] = []; let i = 0; const fake = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; calls.push({ url, init }); if (i >= responses.length) { throw new Error(`recordingFetch: no response for call #${i}`); } return responses[i++]!; }; return { fetch: fake as typeof globalThis.fetch, calls }; }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("ApiClient defaults", () => { it("uses production origins when no overrides are given", async () => { const { fetch, calls } = recordingFetch(jsonResponse({ ok: true })); const client = new ApiClient({ fetch }); await client.getJSON("/health"); expect(calls[0]!.url).toBe("https://api.ente.io/health"); }); it("attaches X-Client-Package to every request", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch }); await client.getJSON("/ping"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quack"); }); }); describe("ApiClient auth token", () => { it("does not send X-Auth-Token when no token is set", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch }); await client.getJSON("/public"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.has("X-Auth-Token")).toBe(false); }); it("sends X-Auth-Token after setAuthToken", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch }); client.setAuthToken("my-token-123"); await client.getJSON("/private"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("X-Auth-Token")).toBe("my-token-123"); }); it("accepts authToken in constructor options", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch, authToken: "ctor-token" }); await client.getJSON("/private"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("X-Auth-Token")).toBe("ctor-token"); }); it("stops sending X-Auth-Token after clearAuthToken", async () => { const { fetch, calls } = recordingFetch( jsonResponse({}), jsonResponse({}), ); const client = new ApiClient({ fetch, authToken: "temp" }); await client.getJSON("/a"); client.clearAuthToken(); await client.getJSON("/b"); const h0 = new Headers(calls[0]!.init?.headers as HeadersInit); const h1 = new Headers(calls[1]!.init?.headers as HeadersInit); expect(h0.has("X-Auth-Token")).toBe(true); expect(h1.has("X-Auth-Token")).toBe(false); }); }); describe("ApiClient.getJSON", () => { it("sends a GET request and parses JSON", async () => { const { fetch, calls } = recordingFetch( jsonResponse({ collections: [1, 2, 3] }), ); const client = new ApiClient({ fetch }); const result = await client.getJSON<{ collections: number[] }>( "/collections/v2", ); expect(calls[0]!.init?.method).toBe("GET"); expect(result.collections).toEqual([1, 2, 3]); }); it("appends query parameters to the URL", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch }); await client.getJSON("/users/srp/attributes", { email: "a@b.c", sinceTime: 12345, }); const url = new URL(calls[0]!.url); expect(url.searchParams.get("email")).toBe("a@b.c"); expect(url.searchParams.get("sinceTime")).toBe("12345"); }); it("omits query parameters whose value is undefined", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch }); await client.getJSON("/x", { keep: "yes", drop: undefined }); const url = new URL(calls[0]!.url); expect(url.searchParams.has("keep")).toBe(true); expect(url.searchParams.has("drop")).toBe(false); }); }); describe("ApiClient.postJSON", () => { it("sends a POST with JSON body and Content-Type", async () => { const { fetch, calls } = recordingFetch( jsonResponse({ sessionID: "abc" }), ); const client = new ApiClient({ fetch }); const result = await client.postJSON<{ sessionID: string }>( "/users/srp/create-session", { userID: "u1", A: "aaa" }, ); expect(calls[0]!.init?.method).toBe("POST"); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("Content-Type")).toBe("application/json"); expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({ userID: "u1", A: "aaa", }); expect(result.sessionID).toBe("abc"); }); }); describe("ApiClient custom origins", () => { it("uses a custom apiOrigin for API calls", async () => { const { fetch, calls } = recordingFetch(jsonResponse({})); const client = new ApiClient({ fetch, apiOrigin: "https://my-ente.example.com", }); await client.getJSON("/health"); expect(calls[0]!.url).toBe("https://my-ente.example.com/health"); }); it("routes file downloads through apiOrigin when custom", async () => { // Self-hosted servers don't have dedicated CDN hosts. File // downloads use the API origin at /files/download/. const body = new Uint8Array([0xca, 0xfe]); const { fetch, calls } = recordingFetch(streamResponse(body)); const client = new ApiClient({ fetch, apiOrigin: "https://my-ente.example.com", }); await client.getFileStream(99); expect(calls[0]!.url).toBe( "https://my-ente.example.com/files/download/99", ); }); it("routes thumbnail downloads through apiOrigin when custom", async () => { const body = new Uint8Array([0xde, 0xad]); const { fetch, calls } = recordingFetch(streamResponse(body)); const client = new ApiClient({ fetch, apiOrigin: "https://my-ente.example.com", }); await client.getThumbnailStream(77); expect(calls[0]!.url).toBe( "https://my-ente.example.com/files/preview/77", ); }); it("uses production CDN hosts when apiOrigin is default", async () => { const body = new Uint8Array([1, 2, 3]); const { fetch, calls } = recordingFetch( streamResponse(body), streamResponse(body), ); const client = new ApiClient({ fetch }); await client.getFileStream(10); await client.getThumbnailStream(20); expect(calls[0]!.url).toBe("https://files.ente.io/?fileID=10"); expect(calls[1]!.url).toBe("https://thumbnails.ente.io/?fileID=20"); }); }); describe("ApiError", () => { it("throws ApiError on 4xx with status, code, requestID", async () => { const { fetch } = recordingFetch( jsonResponse({ code: "INVALID_TOKEN", message: "bad token" }, 401, { "x-request-id": "req-abc", }), ); const client = new ApiClient({ fetch }); try { await client.getJSON("/protected"); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ApiError); const apiErr = err as ApiError; expect(apiErr.status).toBe(401); expect(apiErr.code).toBe("INVALID_TOKEN"); expect(apiErr.requestID).toBe("req-abc"); expect(apiErr.body).toEqual({ code: "INVALID_TOKEN", message: "bad token", }); } }); it("throws ApiError on 5xx", async () => { const { fetch } = recordingFetch( textResponse("Internal Server Error", 500, { "x-request-id": "req-xyz", }), ); const client = new ApiClient({ fetch }); try { await client.postJSON("/boom", {}); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ApiError); const apiErr = err as ApiError; expect(apiErr.status).toBe(500); expect(apiErr.requestID).toBe("req-xyz"); } }); it("throws ApiError on non-2xx stream downloads", async () => { const { fetch } = recordingFetch( textResponse("Not Found", 404, { "x-request-id": "req-404" }), ); const client = new ApiClient({ fetch }); try { await client.getFileStream(999); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ApiError); expect((err as ApiError).status).toBe(404); } }); }); describe("ApiClient.getFileStream / getThumbnailStream", () => { it("returns a ReadableStream on success", async () => { const payload = new Uint8Array([10, 20, 30, 40]); const { fetch } = recordingFetch(streamResponse(payload)); const client = new ApiClient({ fetch }); const stream = await client.getFileStream(5); expect(stream).toBeInstanceOf(ReadableStream); const reader = stream.getReader(); const chunks: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const all = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); let offset = 0; for (const c of chunks) { all.set(c, offset); offset += c.length; } expect(all).toEqual(payload); }); it("attaches X-Auth-Token to CDN downloads", async () => { const { fetch, calls } = recordingFetch( streamResponse(new Uint8Array(0)), ); const client = new ApiClient({ fetch, authToken: "tk" }); await client.getFileStream(1); const headers = new Headers(calls[0]!.init?.headers as HeadersInit); expect(headers.get("X-Auth-Token")).toBe("tk"); }); });