Files

166 lines
7.0 KiB
TypeScript

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<string, string> = {}) =>
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("safety", () => {
test("HttpError message does not leak the query string", async () => {
const { fetchImpl } = seq(res(404, {}));
const http = createHttpClient({ userAgent: "t/1", fetchImpl, sleep: async () => {} });
const err = (await http.json("https://x.test/a?api_key=SECRET").catch((e) => e)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.message).not.toContain("SECRET");
expect(err.message).toContain("https://x.test/a");
});
test("createTestContext without fetch never hits the network", async () => {
const ctx = createTestContext();
await expect(ctx.http.json("https://x.test/a")).rejects.toThrow("createTestContext: no fetch provided");
});
});
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();
});
});