Files
time_to_leave/src/lib/__tests__/api-service.test.ts
T

399 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MemoryCache, ApiClient, ApiError, fetchWithRetry, cachedFetch, calculateBackoff, sleep } from "../api-service";
// ---------------------------------------------------------------------------
// MemoryCache Tests
// ---------------------------------------------------------------------------
describe("MemoryCache", () => {
let cache: MemoryCache<string>;
beforeEach(() => {
vi.useFakeTimers();
cache = new MemoryCache({ defaultTtlMs: 5000 });
});
afterEach(() => {
vi.useRealTimers();
});
it("stores and retrieves values", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
});
it("returns null for missing keys", () => {
expect(cache.get("missing")).toBeNull();
});
it("expires entries after TTL", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBeNull();
});
it("supports per-entry TTL override", () => {
cache.set("key", "value", 10000);
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(5000);
expect(cache.get("key")).toBeNull();
});
it("invalidates specific keys", () => {
cache.set("key", "value");
expect(cache.invalidate("key")).toBe(true);
expect(cache.get("key")).toBeNull();
expect(cache.invalidate("key")).toBe(false);
});
it("clears all entries", () => {
cache.set("a", "1");
cache.set("b", "2");
cache.clear();
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBeNull();
});
it("evicts oldest entry when max size exceeded", () => {
const smallCache = new MemoryCache<string>({ defaultTtlMs: 60_000, maxSize: 2 });
smallCache.set("first", "1");
vi.advanceTimersByTime(100);
smallCache.set("second", "2");
vi.advanceTimersByTime(100);
smallCache.set("third", "3");
// Oldest entry should be evicted
expect(smallCache.get("first")).toBeNull();
expect(smallCache.get("second")).toBe("2");
expect(smallCache.get("third")).toBe("3");
});
it("tracks hit/miss statistics", () => {
cache.set("key", "value");
cache.get("key"); // hit
cache.get("missing"); // miss
cache.get("key"); // hit
const stats = cache.stats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.6667, 2);
});
});
// ---------------------------------------------------------------------------
// calculateBackoff Tests
// ---------------------------------------------------------------------------
describe("calculateBackoff", () => {
it("doubles delay exponentially", () => {
// Attempt 0: baseDelay * 2^0 = 100
const d0 = calculateBackoff(0, 100, 10000, 0);
expect(d0).toBeGreaterThanOrEqual(100);
expect(d0).toBeLessThanOrEqual(100);
// Attempt 1: baseDelay * 2^1 = 200
const d1 = calculateBackoff(1, 100, 10000, 0);
expect(d1).toBe(200);
// Attempt 2: baseDelay * 2^2 = 400
const d2 = calculateBackoff(2, 100, 10000, 0);
expect(d2).toBe(400);
});
it("caps delay at maxDelayMs", () => {
const d = calculateBackoff(10, 100, 500, 0);
expect(d).toBe(500);
});
it("adds jitter within expected range", () => {
// With jitter=0.3 and base delay 1000, max delay 10000
// Attempt 0: 1000 + [0, 300]
const d = calculateBackoff(0, 1000, 10000, 0.3);
expect(d).toBeGreaterThanOrEqual(1000);
expect(d).toBeLessThanOrEqual(1300);
});
});
// ---------------------------------------------------------------------------
// fetchWithRetry Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("fetchWithRetry", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns response on first success", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, { maxRetries: 3 });
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("retries on HTTP 500 with backoff", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Server Error" })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("retries on HTTP 429", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 429,
headers: new Headers({ "Retry-After": "0" }),
})
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("throws ApiError after exhausting retries", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 503 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 1,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
});
it("retries on network errors (TypeError)", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch
.mockRejectedValueOnce(new TypeError("network failure"))
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not retry on non-retryable status (404)", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("respects maxRetries limit", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({ ok: false, status: 500 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 2,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
});
// ---------------------------------------------------------------------------
// cachedFetch Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("cachedFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches successful responses", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
const result1 = await cachedFetch(cache, "https://example.com", undefined, {});
const result2 = await cachedFetch(cache, "https://example.com", undefined, {});
expect(result1).toEqual({ data: "hello" });
expect(result2).toEqual({ data: "hello" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("bypasses cache when skipCache is true", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses custom cache key when provided", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
// Same cache key, different URL
await cachedFetch(cache, "https://example.com/a", undefined, { cacheKey: "shared" });
await cachedFetch(cache, "https://example.com/b", undefined, { cacheKey: "shared" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws on non-OK response after retries", async () => {
const mockFetch = vi.mocked(fetch as never);
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
});
const cache = new MemoryCache();
await expect(cachedFetch(cache, "https://example.com", undefined, { maxRetries: 0 })).rejects.toThrow(ApiError);
});
});
// ---------------------------------------------------------------------------
// ApiClient Tests
// ---------------------------------------------------------------------------
describe("ApiClient", () => {
let client: ApiClient;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
client = new ApiClient({
baseUrl: "https://api.example.com",
defaultTimeoutMs: 5000,
defaultTtlMs: 30_000,
maxRetries: 2,
userAgent: "TestClient/1.0",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("builds correct headers with User-Agent", () => {
expect(client.headers).toHaveProperty("User-Agent", "TestClient/1.0");
});
it("provides cache statistics", () => {
const stats = client.cacheStats();
expect(stats.size).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it("clears cache", () => {
client.clearCache();
const stats = client.cacheStats();
expect(stats.size).toBe(0);
});
});
// ---------------------------------------------------------------------------
// ApiError Tests
// ---------------------------------------------------------------------------
describe("ApiError", () => {
it("can be constructed with status and flags", () => {
const err = new ApiError("Rate limited");
err.status = 429;
(err as ApiError).isRateLimit = true;
expect(err.message).toBe("Rate limited");
expect(err.status).toBe(429);
expect((err as ApiError).isRateLimit).toBe(true);
});
it("is an instance of Error", () => {
const err = new ApiError("Something broke");
expect(err).toBeInstanceOf(Error);
});
it("has name ApiError", () => {
const err = new ApiError("oops");
expect(err.name).toBe("ApiError");
});
});
// ---------------------------------------------------------------------------
// sleep Tests
// ---------------------------------------------------------------------------
describe("sleep", () => {
it("resolves after specified delay", async () => {
vi.useFakeTimers();
const promise = sleep(100);
expect(promise).toBeDefined();
vi.advanceTimersByTime(100);
await promise;
vi.useRealTimers();
});
});