MessageWeave/cloudflare-worker/test/lb.test.js

173 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 单元测试:透传转发 + 有界故障转移 + 全失败 5xx(SAF-LB-PASSTHRU / SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE)。
* 运行:cd cloudflare-worker && node --test
*/
import test from "node:test";
import assert from "node:assert/strict";
import { forwardWithFailover, joinUrl, isRetryableError } from "../src/lb.js";
/**
* 可控 fetch mock:steps[i] 每次调用消费一个;
* - {status, body?} → 返回该 Response;
* - {error: Error} → 抛出。
*/
function mockFetch(steps) {
const calls = [];
const fetch = async (url, init) => {
const i = calls.length;
calls.push({ url, init });
const step = steps[Math.min(i, steps.length - 1)];
if (step.error) throw step.error;
return new Response(step.body ?? "ok", { status: step.status, headers: { "content-type": "text/plain" } });
};
return { fetch, calls };
}
/** 测试用计时器:不真触发超时。 */
const noOpTimers = { setTimeout: () => 0, clearTimeout: () => {} };
function req(path = "/reconcile") {
return new Request(`https://lb.example${path}`, {
method: "POST",
headers: { authorization: "Bearer secret-token", "content-type": "application/json" },
body: JSON.stringify({ id: 42 }),
});
}
function abortError() {
const err = new Error("The operation was aborted");
err.name = "AbortError";
return err;
}
test("isRetryableError: only 5xx (status) and timeout/network (err) are retryable", () => {
assert.equal(isRetryableError(new Error("boom"), 0), false, "generic error not retryable");
assert.equal(isRetryableError(abortError(), 0), true, "AbortError retryable");
assert.equal(isRetryableError(new Error("fetch failed"), 0), true, "network-class message retryable");
assert.equal(isRetryableError(new Error("bad inner state"), 0), false, "non-network error not retryable");
assert.equal(isRetryableError(null, 503), true, "5xx retryable");
assert.equal(isRetryableError(null, 404), false, "4xx not retryable");
assert.equal(isRetryableError(null, 200), false, "2xx not retryable");
});
test("transparent passthrough forwards headers + body + method (SAF-LB-PASSTHRU)", async () => {
const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]);
const res = await forwardWithFailover(["https://a.example"], req("/webhook/tg"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0.5,
});
assert.equal(res.status, 202);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "https://a.example/webhook/tg");
assert.equal(calls[0].init.method, "POST");
assert.equal(calls[0].init.headers.get("authorization"), "Bearer secret-token");
assert.equal(calls[0].init.headers.get("content-type"), "application/json");
const body = await new Response(new Uint8Array(calls[0].init.body)).text();
assert.equal(body, JSON.stringify({ id: 42 }));
assert.equal(res.headers.get("x-lb-backend"), "https://a.example");
});
test("GET/HEAD do not forward a body", async () => {
const { fetch, calls } = mockFetch([{ status: 200 }]);
const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } });
await forwardWithFailover(["https://a.example"], get, {
...noOpTimers,
fetch,
maxAttempts: 1,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(calls[0].init.body, undefined, "GET must not carry a body");
});
test("4xx is returned as-is WITHOUT retry and without failing over", async () => {
const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]);
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 401);
assert.equal(calls.length, 1, "must NOT retry/share next backend on 4xx");
assert.equal(calls[0].url, "https://a.example/push/jmap");
});
test("5xx triggers failover to next origin, then returns success", async () => {
const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]);
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0, // 确定性起点 a
});
assert.equal(res.status, 200);
assert.equal(calls.length, 2);
assert.equal(calls[0].url, "https://a.example/push/jmap");
assert.equal(calls[1].url, "https://b.example/push/jmap");
assert.equal(res.headers.get("x-lb-backend"), "https://b.example");
});
test("timeout(AbortError) triggers failover and recovery", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]);
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 200);
assert.equal(calls.length, 2);
});
test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]);
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 2);
assert.match(await res.text(), /all backends failed/);
});
test("bounded attempts: maxAttempts caps the number of origin tries", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移)
const res = await forwardWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 2, "must stop after maxAttempts=2 (bounded, no infinite retry)");
});
test("non-retryable (non-network) error fails fast without burning other origins", async () => {
const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]);
const res = await forwardWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 1, "non-network error must not failover");
});
test("joinUrl preserves path + query, discards fragment", () => {
const r = new Request("https://lb.example/reconcile?cursor=abc#frag", { method: "POST" });
assert.equal(joinUrl("https://a.example", r), "https://a.example/reconcile?cursor=abc");
});