|
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
|
import { ApiClient } from "../client";
|
|
|
|
|
|
|
|
|
|
describe("ApiClient", () => {
|
|
|
|
|
it("constructs with a single base URL", () => {
|
|
|
|
|
const client = new ApiClient("https://example.com");
|
|
|
|
|
expect(client).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("constructs with multiple base URLs and deduplicates", () => {
|
|
|
|
|
const client = new ApiClient(["https://a.com", "https://a.com", "https://b.com"]);
|
|
|
|
|
expect(client).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("constructs with empty base URL (same-origin)", () => {
|
|
|
|
|
const client = new ApiClient();
|
|
|
|
|
expect(client).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips whitespace from base URLs", () => {
|
|
|
|
|
const client = new ApiClient([" https://a.com ", " https://b.com "]);
|
|
|
|
|
expect(client).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("ApiClient failover", () => {
|
|
|
|
|
it("retries the next base URL on 502", async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
globalThis.fetch = vi.fn(async (url: string) => {
|
|
|
|
|
calls++;
|
|
|
|
|
if (url.includes("primary")) {
|
|
|
|
|
return new Response(null, { status: 502 });
|
|
|
|
|
}
|
|
|
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient(["https://primary.com", "https://backup.com"]);
|
|
|
|
|
const res = await (client as unknown as { fetchApi: (path: string) => Promise<Response> }).fetchApi("/api/health");
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
expect(calls).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retries the next base URL on 503", async () => {
|
|
|
|
|
globalThis.fetch = vi.fn(async (url: string) => {
|
|
|
|
|
if (url.includes("primary")) {
|
|
|
|
|
return new Response(null, { status: 503 });
|
|
|
|
|
}
|
|
|
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient(["https://primary.com", "https://backup.com"]);
|
|
|
|
|
const res = await (client as unknown as { fetchApi: (path: string) => Promise<Response> }).fetchApi("/api/health");
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not retry on 400", async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
globalThis.fetch = vi.fn(async () => {
|
|
|
|
|
calls++;
|
|
|
|
|
return new Response(null, { status: 400 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient(["https://primary.com", "https://backup.com"]);
|
|
|
|
|
const res = await (client as unknown as { fetchApi: (path: string) => Promise<Response> }).fetchApi("/api/health");
|
|
|
|
|
expect(res.status).toBe(400);
|
|
|
|
|
expect(calls).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("retries on network error and returns last response", async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
globalThis.fetch = vi.fn(async (url: string) => {
|
|
|
|
|
calls++;
|
|
|
|
|
if (url.includes("primary")) {
|
|
|
|
|
throw new Error("Network failure");
|
|
|
|
|
}
|
|
|
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient(["https://primary.com", "https://backup.com"]);
|
|
|
|
|
const res = await (client as unknown as { fetchApi: (path: string) => Promise<Response> }).fetchApi("/api/health");
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
expect(calls).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("throws when all base URLs fail", async () => {
|
|
|
|
|
globalThis.fetch = vi.fn(async () => {
|
|
|
|
|
throw new Error("Everything is down");
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient(["https://a.com", "https://b.com"]);
|
|
|
|
|
await expect(
|
|
|
|
|
(client as unknown as { fetchApi: (path: string) => Promise<Response> }).fetchApi("/api/health"),
|
|
|
|
|
).rejects.toThrow("Everything is down");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("ApiClient.getHealth", () => {
|
|
|
|
|
it("parses the health response shape", async () => {
|
|
|
|
|
globalThis.fetch = vi.fn(async () =>
|
|
|
|
|
new Response(JSON.stringify({ ok: true, ts: 1234567890, version: "1.0.0" }), { status: 200 }),
|
|
|
|
|
) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const client = new ApiClient("https://example.com");
|
|
|
|
|
const health = await client.getHealth();
|
|
|
|
|
expect(health.ok).toBe(true);
|
|
|
|
|
expect(health.ts).toBe(1234567890);
|
|
|
|
|
expect(health.version).toBe("1.0.0");
|
|
|
|
|
});
|
|
|
|
|
});
|