Files
mangabaka-seeder/test/transport.test.ts
T

105 lines
4.8 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
TOOL_USER_AGENT, TransportError, userAgentFor, withPolicy, type PolicyOptions,
} from "../src/core/transport";
import { FakeTransport, text } from "./helpers/fake-transport";
function fakeClock() {
let now = 0;
const slept: number[] = [];
const opts: PolicyOptions = {
now: () => now,
sleep: async (ms) => { slept.push(ms); now += ms; },
};
return { opts, slept, advance: (ms: number) => { now += ms; } };
}
describe("withPolicy", () => {
test("spaces requests to the same host, not across hosts", async () => {
const fake = new FakeTransport().on(() => true, () => text("ok"));
const clock = fakeClock();
const t = withPolicy(fake, clock.opts);
await t.fetch("https://www.webtoons.com/a");
await t.fetch("https://www.webtoons.com/b");
await t.fetch("https://swebtoon-phinf.pstatic.net/c");
await t.fetch("https://www.webtoons.com/d");
expect(clock.slept).toEqual([1000, 1000]);
});
test("waits 10 s between Wayback requests", async () => {
const fake = new FakeTransport().on(() => true, () => text("ok"));
const clock = fakeClock();
const t = withPolicy(fake, clock.opts);
await t.fetch("https://web.archive.org/a");
await t.fetch("https://web.archive.org/b");
expect(clock.slept).toEqual([10_000]);
});
test("retries GET on 429/5xx, honouring Retry-After", async () => {
let n = 0;
const fake = new FakeTransport().on(() => true, () =>
++n === 1 ? text("slow down", 429, { "retry-after": "7" }) : n === 2 ? text("oops", 503) : text("ok"));
const clock = fakeClock();
const res = await withPolicy(fake, { ...clock.opts, minIntervalMs: () => 0 }).fetch("https://www.webtoons.com/x");
expect(res.status).toBe(200);
expect(fake.calls.length).toBe(3);
expect(clock.slept).toEqual([7000, 4000]); // Retry-After, then backoff 2000 * 2^1
});
test("returns the last 5xx after the retries are used up", async () => {
const fake = new FakeTransport().on(() => true, () => text("down", 502));
const clock = fakeClock();
const res = await withPolicy(fake, { ...clock.opts, minIntervalMs: () => 0 }).fetch("https://www.webtoons.com/x");
expect(res.status).toBe(502);
expect(fake.calls.length).toBe(3);
});
test("never retries POST", async () => {
const fake = new FakeTransport().on(() => true, () => text("bad gateway", 502));
const clock = fakeClock();
const res = await withPolicy(fake, clock.opts).fetch("https://api.mangabaka.org/v0/my/submissions/series-images/1", {
method: "POST", body: "{}",
});
expect(res.status).toBe(502);
expect(fake.calls.length).toBe(1);
});
test("retries network errors on GET, then throws TransportError", async () => {
const fake = new FakeTransport().on(() => true, () => { throw new TypeError("connection reset"); });
const clock = fakeClock();
const t = withPolicy(fake, { ...clock.opts, minIntervalMs: () => 0 });
await expect(t.fetch("https://www.webtoons.com/x")).rejects.toBeInstanceOf(TransportError);
expect(fake.calls.length).toBe(3);
});
test("times out a hanging request", async () => {
const hang = { fetch: (_url: string, init?: { signal?: AbortSignal }) =>
new Promise<Response>((_, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason))) };
const t = withPolicy(hang, { timeoutMs: 30, retries: 0, minIntervalMs: () => 0 });
await expect(t.fetch("https://www.webtoons.com/x")).rejects.toBeInstanceOf(TransportError);
});
test("refuses to send a token anywhere but api.mangabaka.org", async () => {
const fake = new FakeTransport().on(() => true, () => text("ok"));
const t = withPolicy(fake, { minIntervalMs: () => 0 });
await expect(t.fetch("https://evil.example/x", { headers: { "X-Api-Key": "mb-secret" } })).rejects.toThrow(/credentials/);
expect(fake.calls.length).toBe(0);
await t.fetch("https://api.mangabaka.org/v1/series/1", { headers: { "x-api-key": "mb-secret" } });
expect(fake.calls.length).toBe(1);
});
test("a caller abort is not retried", async () => {
const controller = new AbortController();
const fake = new FakeTransport().on(() => true, () => { controller.abort(); throw new DOMException("Aborted", "AbortError"); });
const t = withPolicy(fake, { minIntervalMs: () => 0 });
await expect(t.fetch("https://www.webtoons.com/x", { signal: controller.signal })).rejects.toThrow();
expect(fake.calls.length).toBe(1);
});
});
test("Wayback gets the honest User-Agent, everything else a browser one", () => {
expect(userAgentFor("https://web.archive.org/cdx/search/cdx?url=x")).toBe(TOOL_USER_AGENT);
expect(TOOL_USER_AGENT).toMatch(/^mangabaka-seeder\/\d+\.\d+\.\d+ \(\+https:\/\//);
expect(userAgentFor("https://www.webtoons.com/en/x")).toMatch(/^Mozilla\/5\.0/);
});