feat(sdk): plugin contract, http client, fixture harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
27ec24eb4b
commit
a14c4e7747
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { comparePartialDates, parsePartialDate } from "../src/partial-date.ts";
|
||||
|
||||
describe("parsePartialDate", () => {
|
||||
const table: [string | null | undefined, { value: string; precision: string } | null][] = [
|
||||
["2019", { value: "2019", precision: "year" }],
|
||||
["2019-03", { value: "2019-03", precision: "month" }],
|
||||
["2019-03-04", { value: "2019-03-04", precision: "day" }],
|
||||
["2019-03-04T10:20:30Z", { value: "2019-03-04T10:20:30Z", precision: "day" }],
|
||||
["2019-03-04T10:20:30.123+02:00", { value: "2019-03-04T10:20:30.123+02:00", precision: "day" }],
|
||||
["03/04/2019", null],
|
||||
["", null],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
["2019-13", null],
|
||||
["2019-02-30", null],
|
||||
["garbage", null],
|
||||
];
|
||||
for (const [input, expected] of table) {
|
||||
test(`${JSON.stringify(input)}`, () => {
|
||||
expect(parsePartialDate(input)).toEqual(expected as never);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("comparePartialDates", () => {
|
||||
test("orders by value", () => {
|
||||
const y = { value: "2019", precision: "year" as const };
|
||||
const m = { value: "2019-03", precision: "month" as const };
|
||||
const d = { value: "2019-03-04", precision: "day" as const };
|
||||
expect(comparePartialDates(y, m)).toBeLessThan(0);
|
||||
expect(comparePartialDates(d, m)).toBeGreaterThan(0);
|
||||
expect(comparePartialDates(d, d)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user