150 lines
6.3 KiB
TypeScript
150 lines
6.3 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("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();
|
|
});
|
|
});
|