Add tests and improve localStorage resilience
CI / lint-typecheck-test (push) Has been cancelled

This commit is contained in:
2026-05-19 14:46:03 +02:00
parent 72f9400756
commit 7da5acbba3
18 changed files with 302 additions and 119 deletions
+4 -2
View File
@@ -6,12 +6,14 @@
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@timetoleave/core": "*"
},
"devDependencies": {
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.5"
}
}
@@ -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");
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ export class ApiClient {
}
}
async getHealth(): Promise<{ status: "ok"; uptime: number }> {
async getHealth(): Promise<{ ok: boolean; ts: number; version: string }> {
const res = await this.fetchApi("/api/health");
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
return res.json();
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
},
});