Initial Commit
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* `HttpsTransport` long-poll receive loop — covers happy dispatch,
|
||||
* cursor advance, malformed-body retry, transient-failure backoff,
|
||||
* auth-failure termination, batch-yield, and abort.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { MessageEnvelope } from "@suaveplan/types";
|
||||
import {
|
||||
HTTPS_TRANSPORT_CODES,
|
||||
HttpsTransport,
|
||||
RECV_BATCH_CAP,
|
||||
} from "../src/index.js";
|
||||
import {
|
||||
type FakeFetchCall,
|
||||
jsonResponse,
|
||||
makeEnvelope,
|
||||
makeFakeFetch,
|
||||
waitUntil,
|
||||
} from "./_helpers.js";
|
||||
|
||||
const RELAY = "https://relay.test";
|
||||
|
||||
describe("HttpsTransport.recv — long-poll loop", () => {
|
||||
it("dispatches envelopes returned by /recv to handlers and advances cursor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const envelopes = [
|
||||
makeEnvelope({ id: "a" }),
|
||||
makeEnvelope({ id: "b" }),
|
||||
];
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls === 1) {
|
||||
return jsonResponse({
|
||||
envelopes,
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 50,
|
||||
pollIdleDelayMs: 10,
|
||||
});
|
||||
const seen: MessageEnvelope[] = [];
|
||||
t.onMessage((env) => seen.push(env));
|
||||
t.start();
|
||||
await waitUntil(() => seen.length === 2);
|
||||
expect(seen[0]?.id).toBe("a");
|
||||
expect(seen[1]?.id).toBe("b");
|
||||
expect(t.cursor).toBe("cursor-1");
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("includes since=cursor on subsequent /recv calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const history: FakeFetchCall[] = [];
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls === 1) {
|
||||
return jsonResponse({
|
||||
envelopes: [makeEnvelope()],
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: "cursor-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
history,
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.onMessage(() => undefined);
|
||||
t.start();
|
||||
await waitUntil(() => recvCalls >= 2);
|
||||
const secondRecv = history.filter((c) => c.url.includes("/recv"))[1];
|
||||
expect(secondRecv?.url).toContain("since=cursor-1");
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("AUTH_FAILED on /recv stops the loop", { timeout: 5000 }, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
return new Response("nope", { status: 403 });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.state === "stopped");
|
||||
expect(recvCalls).toBe(1);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("backs off on 503 and retries", { timeout: 5000 }, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls < 3) {
|
||||
return new Response("down", { status: 503 });
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "ok" });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.cursor === "ok");
|
||||
expect(recvCalls).toBeGreaterThanOrEqual(3);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("backs off on 4xx other than auth (RECV_FAILED) and retries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls < 2) {
|
||||
return new Response("bad", { status: 418 });
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "ok" });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.cursor === "ok");
|
||||
expect(recvCalls).toBeGreaterThanOrEqual(2);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("backs off on malformed JSON body (MALFORMED_RESPONSE) and retries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls < 2) {
|
||||
return new Response("not-json{{", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "ok" });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.cursor === "ok");
|
||||
expect(recvCalls).toBeGreaterThanOrEqual(2);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("backs off on unexpected JSON shape (MALFORMED_RESPONSE) and retries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls < 2) {
|
||||
return jsonResponse({ wrong: "shape" });
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "ok" });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.cursor === "ok");
|
||||
expect(recvCalls).toBeGreaterThanOrEqual(2);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("yields after RECV_BATCH_CAP envelopes per long-poll batch", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
let dispatched = 0;
|
||||
const big = Array.from({ length: RECV_BATCH_CAP + 5 }, (_, i) =>
|
||||
makeEnvelope({ id: `e-${i}` })
|
||||
);
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls === 1) {
|
||||
return jsonResponse({
|
||||
envelopes: big,
|
||||
nextCursor: "post-batch",
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: "post-batch",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 50,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.onMessage(() => {
|
||||
dispatched++;
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => dispatched === big.length);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("a handler that throws does not poison the loop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls <= 2) {
|
||||
return jsonResponse({
|
||||
envelopes: [makeEnvelope({ id: `e-${recvCalls}` })],
|
||||
nextCursor: `c-${recvCalls}`,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: `c-${recvCalls}`,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
t.onMessage(() => {
|
||||
throw new Error("subscriber-A");
|
||||
});
|
||||
t.onMessage((e) => {
|
||||
seen.push(e.id);
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => seen.length >= 2);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("unsubscribed handlers stop receiving envelopes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Single-shot fetch: return one envelope then empty forever so
|
||||
// the loop quiesces rather than spinning.
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls === 1) {
|
||||
return jsonResponse({
|
||||
envelopes: [makeEnvelope({ id: "first" })],
|
||||
nextCursor: "c-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "c-1" });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
const off = t.onMessage((e) => seen.push(e.id));
|
||||
t.start();
|
||||
await waitUntil(() => seen.length >= 1);
|
||||
expect(seen).toEqual(["first"]);
|
||||
// After unsubscribing, the same handler should never be called
|
||||
// again — but we still need to verify the loop kept running.
|
||||
off();
|
||||
const recvBeforeStop = recvCalls;
|
||||
await waitUntil(() => recvCalls > recvBeforeStop);
|
||||
await t.stop();
|
||||
expect(seen).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("AUTH_FAILED on /recv propagates via lastAuthError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
return new Response("nope", { status: 401 });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
});
|
||||
expect(t.lastAuthError).toBeUndefined();
|
||||
t.start();
|
||||
await waitUntil(() => t.lastAuthError !== undefined);
|
||||
expect(t.state).toBe("stopped");
|
||||
const last = t.lastAuthError;
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.code).toBe(HTTPS_TRANSPORT_CODES.AUTH_FAILED);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("handles zero-ms backoff sleep deterministically", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
if (recvCalls < 2) {
|
||||
return new Response("bad", { status: 500 });
|
||||
}
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: "ok",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-zero",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
pollIdleDelayMs: 5,
|
||||
// Zero jitter — `computeBackoff(1)` = floor(1/2 + 0) = 0,
|
||||
// forcing the `sleep(0, signal)` early-return path.
|
||||
random: () => 0,
|
||||
});
|
||||
t.start();
|
||||
await waitUntil(() => t.cursor === "ok");
|
||||
expect(recvCalls).toBeGreaterThanOrEqual(2);
|
||||
await t.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user