399 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|