import { describe, expect, test } from "bun:test"; import { createHttpClient, HttpError } from "../src/http.ts"; import { collect, createFixtureHttp, createTestContext } from "../src/testing.ts"; import { definePlugin } from "../src/types.ts"; const res = (status: number, body: unknown = {}, headers: Record = {}) => new Response(JSON.stringify(body), { status, headers }); function seq(...responses: Response[]) { const reqs: Request[] = []; const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { reqs.push(new Request(input, init)); return responses.shift()!; }) as typeof fetch; return { fetchImpl, reqs }; } describe("http client", () => { test("retries 429 honouring Retry-After", async () => { const { fetchImpl } = seq(res(429, {}, { "Retry-After": "2" }), res(200, { ok: 1 })); const sleeps: number[] = []; const http = createHttpClient({ userAgent: "t/1", fetchImpl, sleep: async (ms) => void sleeps.push(ms) }); expect(await http.json<{ ok: number }>("https://x.test/a")).toEqual({ ok: 1 }); expect(sleeps).toEqual([2000]); }); test("honours Retry-After as HTTP date and X-RateLimit-Reset-In", async () => { const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); const date = new Date(t0 + 5000).toUTCString(); const { fetchImpl } = seq( res(503, {}, { "Retry-After": date }), res(429, {}, { "X-RateLimit-Reset-In": "3" }), res(200, {}), ); const sleeps: number[] = []; const http = createHttpClient({ userAgent: "t/1", fetchImpl, now: () => t0, sleep: async (ms) => void sleeps.push(ms) }); await http.json("https://x.test/a"); expect(sleeps).toEqual([5000, 3000]); }); test("exponential backoff without headers", async () => { const { fetchImpl } = seq(res(500), res(500), res(200)); const sleeps: number[] = []; const http = createHttpClient({ userAgent: "t/1", fetchImpl, sleep: async (ms) => void sleeps.push(ms) }); await http.json("https://x.test/a"); expect(sleeps.length).toBe(2); expect(sleeps[1]!).toBeGreaterThan(sleeps[0]!); }); test("gives up after maxRetries with HttpError", async () => { const { fetchImpl, reqs } = seq(res(500, { e: 1 }), res(500, { e: 2 }), res(500, { e: 3 })); const http = createHttpClient({ userAgent: "t/1", maxRetries: 2, fetchImpl, sleep: async () => {} }); const err = (await http.json("https://x.test/a").catch((e) => e)) as HttpError; expect(err).toBeInstanceOf(HttpError); expect(err).toBeInstanceOf(Error); expect(err.status).toBe(500); expect(err.body).toBe('{"e":3}'); expect(reqs.length).toBe(3); }); test("non-retryable errors throw immediately from json", async () => { const { fetchImpl, reqs } = seq(res(404, { nope: true })); const http = createHttpClient({ userAgent: "t/1", fetchImpl, sleep: async () => {} }); const err = (await http.json("https://x.test/a").catch((e) => e)) as HttpError; expect(err).toBeInstanceOf(HttpError); expect(err.status).toBe(404); expect(reqs.length).toBe(1); }); test("json sends Accept, User-Agent and appends query", async () => { const { fetchImpl, reqs } = seq(res(200, {})); const http = createHttpClient({ userAgent: "sttracker/1", fetchImpl, sleep: async () => {} }); await http.json("https://x.test/a?z=1", { query: { q: "a b", n: 2, skip: undefined } }); const r = reqs[0]!; expect(r.headers.get("accept")).toBe("application/json"); expect(r.headers.get("user-agent")).toBe("sttracker/1"); const u = new URL(r.url); expect(u.searchParams.get("z")).toBe("1"); expect(u.searchParams.get("q")).toBe("a b"); expect(u.searchParams.get("n")).toBe("2"); expect(u.searchParams.has("skip")).toBe(false); }); test("rate limits to ratePerMinute", async () => { let clock = 1_000_000; const sleeps: number[] = []; const { fetchImpl } = seq(res(200), res(200), res(200)); const http = createHttpClient({ userAgent: "t/1", ratePerMinute: 60, burst: 1, fetchImpl, now: () => clock, sleep: async (ms) => { sleeps.push(ms); clock += ms; }, }); await http.json("https://x.test/1"); await http.json("https://x.test/2"); await http.json("https://x.test/3"); expect(sleeps.length).toBe(2); for (const s of sleeps) expect(Math.abs(s - 1000)).toBeLessThanOrEqual(5); }); }); describe("testing harness", () => { test("fixture routes match by full url or url without query; unknown throws", async () => { const fx = createFixtureHttp({ "GET https://x.test/a": { a: 1 }, "GET https://x.test/b?id=2": { b: 2 }, "POST https://x.test/c": (req: Request) => ({ method: req.method }), }); expect(await (await fx.fetch("https://x.test/a?q=1")).json()).toEqual({ a: 1 }); expect(await (await fx.fetch("https://x.test/b?id=2")).json()).toEqual({ b: 2 }); expect(await (await fx.fetch("https://x.test/c", { method: "POST" })).json()).toEqual({ method: "POST" }); expect(fx.calls.length).toBe(3); await expect(fx.fetch("https://x.test/zzz")).rejects.toThrow(); }); test("createTestContext wires http, credentials and cursor", async () => { const fx = createFixtureHttp({ "GET https://x.test/a": { a: 1 } }); const ctx = createTestContext({ fetch: fx.fetch }); expect(await ctx.http.json("https://x.test/a")).toEqual({ a: 1 }); expect(await ctx.credentials.get()).toBeNull(); await ctx.credentials.save({ token: "t" }); expect(await ctx.credentials.get()).toEqual({ token: "t" }); await ctx.cursor.set("c1"); expect(await ctx.cursor.get()).toBe("c1"); expect(ctx.saved).toEqual({ credentials: [{ token: "t" }], cursor: ["c1"] }); async function* gen() { yield 1; yield 2; } expect(await collect(gen())).toEqual([1, 2]); }); }); describe("definePlugin", () => { const manifest = { id: "p", name: "P", version: "1", sdkVersion: 1 as const, mediaTypes: [], capabilities: [], config: [] }; test("identity for valid manifest", () => { const p = { manifest }; expect(definePlugin(p)).toBe(p); }); test("rejects bad sdkVersion or empty id", () => { expect(() => definePlugin({ manifest: { ...manifest, sdkVersion: 2 as never } })).toThrow(); expect(() => definePlugin({ manifest: { ...manifest, id: "" } })).toThrow(); }); });