19 tests covering ApiClient's full public surface: default and custom origins, X-Client-Package and X-Auth-Token headers, getJSON with query params, postJSON with JSON body, ApiError on 4xx/5xx, streaming file and thumbnail downloads, and self-hosted origin routing. Tests inject a recording fetch via the constructor, so nothing hits the network. The test file is documented to serve as canonical usage reference per the development workflow.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
/**
|
|
* 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/<id>` and `/files/preview/<id>` 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<Uint8Array>` 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<string, string> = {},
|
|
): Response =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
});
|
|
|
|
const textResponse = (
|
|
body: string,
|
|
status = 200,
|
|
headers: Record<string, string> = {},
|
|
): Response =>
|
|
new Response(body, {
|
|
status,
|
|
headers: { "content-type": "text/plain", ...headers },
|
|
});
|
|
|
|
const streamResponse = (
|
|
body: Uint8Array,
|
|
status = 200,
|
|
headers: Record<string, string> = {},
|
|
): 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<Response> => {
|
|
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/<id>.
|
|
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");
|
|
});
|
|
});
|