Initial Commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Shared test helpers for the HttpsTransport test suite. Centralises
|
||||
* fake fetch construction so each test file stays focused on the
|
||||
* behaviour under test.
|
||||
*/
|
||||
|
||||
import type { MessageEnvelope, PeerId } from "@suaveplan/types";
|
||||
import type { FetchFn } from "../src/types/transport.types.js";
|
||||
|
||||
export interface FakeFetchCall {
|
||||
readonly url: string;
|
||||
readonly method: string;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string | undefined;
|
||||
}
|
||||
|
||||
export interface FakeFetchOptions {
|
||||
readonly responder: (call: FakeFetchCall) => Response | Promise<Response>;
|
||||
readonly history?: FakeFetchCall[];
|
||||
}
|
||||
|
||||
export function makeFakeFetch(options: FakeFetchOptions): FetchFn {
|
||||
const history = options.history ?? [];
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const method = init?.method ?? "GET";
|
||||
const headers = init?.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(init.headers as Record<string, string>).map(
|
||||
([k, v]) => [k.toLowerCase(), v]
|
||||
)
|
||||
)
|
||||
: {};
|
||||
const body = typeof init?.body === "string" ? init.body : undefined;
|
||||
const call: FakeFetchCall = { url, method, headers, body };
|
||||
history.push(call);
|
||||
if (init?.signal?.aborted) {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}
|
||||
return options.responder(call);
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export function makeEnvelope(
|
||||
overrides: Partial<MessageEnvelope> = {}
|
||||
): MessageEnvelope {
|
||||
return {
|
||||
id: overrides.id ?? "msg-1",
|
||||
type: overrides.type ?? "ping",
|
||||
senderId: overrides.senderId ?? "peer-a",
|
||||
targetId: overrides.targetId ?? "peer-b",
|
||||
payload: overrides.payload ?? new Uint8Array([1, 2, 3]),
|
||||
timestamp: overrides.timestamp ?? 1_000,
|
||||
headers: overrides.headers ?? {},
|
||||
} as MessageEnvelope;
|
||||
}
|
||||
|
||||
export const TEST_PEER: PeerId = "peer-b";
|
||||
|
||||
/** Yield the microtask queue several times to let async loops progress. */
|
||||
export async function flushMicrotasks(times = 4): Promise<void> {
|
||||
for (let i = 0; i < times; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until `predicate()` returns true or the deadline expires.
|
||||
* Polls with a small real-time delay so backoff timers (which use
|
||||
* real `setTimeout`) get a chance to fire between checks.
|
||||
*/
|
||||
export async function waitUntil(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise<void>((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error("waitUntil: condition never satisfied");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Tests for the `defaultFetch` fallback path: when no `fetchFn` is
|
||||
* passed and `globalThis.fetch` is missing the call rejects with
|
||||
* `NOT_CONNECTED`. These tests sit in their own file so the
|
||||
* globalThis.fetch monkey-patching cannot leak into the
|
||||
* loop-oriented suites.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HttpsTransport, HttpsTransportError } from "../src/index.js";
|
||||
import { makeEnvelope } from "./_helpers.js";
|
||||
|
||||
const RELAY = "https://relay.test";
|
||||
|
||||
describe("HttpsTransport — defaultFetch fallback", () => {
|
||||
it("send rejects with HttpsTransportError when globalThis.fetch is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const orig = globalThis.fetch;
|
||||
// @ts-expect-error — deliberately remove
|
||||
globalThis.fetch = undefined;
|
||||
try {
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
backoffBaseMs: 1,
|
||||
backoffMaxMs: 5,
|
||||
longPollTimeoutMs: 10,
|
||||
pollIdleDelayMs: 0,
|
||||
});
|
||||
t.start();
|
||||
await t.connect("peer-b", "x");
|
||||
await expect(
|
||||
t.send("peer-b", makeEnvelope())
|
||||
).rejects.toBeInstanceOf(HttpsTransportError);
|
||||
await t.stop();
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when no fetchFn is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const orig = globalThis.fetch;
|
||||
let called = 0;
|
||||
const stub = async (_url: string | URL): Promise<Response> => {
|
||||
called++;
|
||||
await Promise.resolve();
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
globalThis.fetch = stub as typeof fetch;
|
||||
try {
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
longPollTimeoutMs: 10,
|
||||
pollIdleDelayMs: 0,
|
||||
});
|
||||
t.start();
|
||||
await t.connect("peer-b", "x");
|
||||
await t.send("peer-b", makeEnvelope());
|
||||
await t.stop();
|
||||
expect(called).toBeGreaterThan(0);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* HTTPS_TRANSPORT_CODES spec conformance — registry frozen, every
|
||||
* code is namespaced under `HTTPS_TRANSPORT.`, every code is usable
|
||||
* to construct an `HttpsTransportError`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HTTPS_TRANSPORT_CODES, HttpsTransportError } from "../src/index.js";
|
||||
|
||||
describe("HTTPS_TRANSPORT_CODES is frozen and namespaced", () => {
|
||||
it("HTTPS_TRANSPORT_CODES is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(HTTPS_TRANSPORT_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("every value namespaced under HTTPS_TRANSPORT.", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const v of Object.values(HTTPS_TRANSPORT_CODES)) {
|
||||
expect(v).toMatch(/^HTTPS_TRANSPORT\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code constructs an HttpsTransportError that preserves it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(HTTPS_TRANSPORT_CODES)) {
|
||||
const e = new HttpsTransportError(code, "ok");
|
||||
expect(e.code).toBe(code);
|
||||
expect(e).toBeInstanceOf(HttpsTransportError);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry contains the nine documented codes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"NOT_CONNECTED",
|
||||
"CLOSED",
|
||||
"SEND_FAILED",
|
||||
"RECV_FAILED",
|
||||
"AUTH_FAILED",
|
||||
"RELAY_UNAVAILABLE",
|
||||
"MALFORMED_RESPONSE",
|
||||
"ABORTED",
|
||||
"PEER_UNKNOWN",
|
||||
] as const;
|
||||
for (const k of expected) {
|
||||
expect(HTTPS_TRANSPORT_CODES).toHaveProperty(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Lifecycle and option-validation tests for `HttpsTransport`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HTTPS_TRANSPORT_CODES,
|
||||
HttpsTransport,
|
||||
type HttpsTransportError,
|
||||
} from "../src/index.js";
|
||||
import { jsonResponse, makeFakeFetch } from "./_helpers.js";
|
||||
|
||||
const RELAY = "https://relay.test";
|
||||
|
||||
describe("HttpsTransport — lifecycle", () => {
|
||||
it("constructor rejects an invalid relayUrl", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new HttpsTransport({
|
||||
relayUrl: "not-a-url",
|
||||
ourPeerId: "peer-a",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("constructor rejects empty ourPeerId", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("constructor rejects backoffBaseMs > backoffMaxMs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
backoffBaseMs: 1000,
|
||||
backoffMaxMs: 100,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("start is idempotent while running", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 50,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
t.start();
|
||||
expect(t.state).toBe("running");
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("start after stop throws CLOSED", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
t.start();
|
||||
await t.stop();
|
||||
try {
|
||||
t.start();
|
||||
throw new Error("expected CLOSED");
|
||||
} catch (err) {
|
||||
expect((err as HttpsTransportError).code).toBe(
|
||||
HTTPS_TRANSPORT_CODES.CLOSED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("stop is idempotent", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
t.start();
|
||||
await t.stop();
|
||||
await t.stop();
|
||||
expect(t.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("stop before start does not throw", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
await t.stop();
|
||||
expect(t.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("connect/disconnect track peers and getConnectedPeers reports them", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 50,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
t.start();
|
||||
await t.connect("peer-b", "ignored");
|
||||
await t.connect("peer-c", "ignored");
|
||||
expect(t.getConnectedPeers()).toEqual(["peer-b", "peer-c"]);
|
||||
await t.disconnect("peer-b");
|
||||
expect(t.getConnectedPeers()).toEqual(["peer-c"]);
|
||||
// disconnect on an unknown peer is a no-op
|
||||
await t.disconnect("peer-x");
|
||||
expect(t.getConnectedPeers()).toEqual(["peer-c"]);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("connect before start throws NOT_CONNECTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ ok: true }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
await expect(t.connect("peer-b", "x")).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("cursor starts empty and updates after a successful /recv", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let recvCalls = 0;
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) => {
|
||||
if (call.url.includes("/recv")) {
|
||||
recvCalls++;
|
||||
return jsonResponse({
|
||||
envelopes: [],
|
||||
nextCursor: "c-1",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
pollIdleDelayMs: 5,
|
||||
});
|
||||
expect(t.cursor).toBe("");
|
||||
expect(t.state).toBe("idle");
|
||||
t.start();
|
||||
// Wait for at least one successful recv cycle to complete.
|
||||
const start = Date.now();
|
||||
while (t.cursor === "" && Date.now() - start < 2000) {
|
||||
await new Promise<void>((r) => setTimeout(r, 5));
|
||||
}
|
||||
expect(t.cursor).toBe("c-1");
|
||||
expect(recvCalls).toBeGreaterThan(0);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("clears handlers and peers on stop", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 25,
|
||||
});
|
||||
t.start();
|
||||
await t.connect("peer-b", "x");
|
||||
const off = t.onMessage(() => undefined);
|
||||
await t.stop();
|
||||
expect(t.getConnectedPeers()).toEqual([]);
|
||||
// Calling unsubscribe after stop is a no-op (set already cleared).
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Boundary schema tests — confirm every schema rejects malformed
|
||||
* input and accepts the canonical shape.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HttpsTransportOptionsSchema,
|
||||
RecvResponseSchema,
|
||||
SendBodySchema,
|
||||
SendResponseSchema,
|
||||
} from "../src/index.js";
|
||||
import { makeEnvelope } from "./_helpers.js";
|
||||
|
||||
describe("HttpsTransportOptionsSchema", () => {
|
||||
it("accepts a minimal valid option bag", { timeout: 5000 }, () => {
|
||||
const r = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "peer-a",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects relative URL", { timeout: 5000 }, () => {
|
||||
const r = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "/relative",
|
||||
ourPeerId: "peer-a",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty ourPeerId", { timeout: 5000 }, () => {
|
||||
const r = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects backoffBaseMs > backoffMaxMs", { timeout: 5000 }, () => {
|
||||
const r = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "peer-a",
|
||||
backoffBaseMs: 1000,
|
||||
backoffMaxMs: 100,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive longPollTimeoutMs", { timeout: 5000 }, () => {
|
||||
const r = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "peer-a",
|
||||
longPollTimeoutMs: 0,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only one backoff bound", { timeout: 5000 }, () => {
|
||||
const a = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "peer-a",
|
||||
backoffBaseMs: 100,
|
||||
});
|
||||
expect(a.success).toBe(true);
|
||||
const b = HttpsTransportOptionsSchema.safeParse({
|
||||
relayUrl: "https://r.test",
|
||||
ourPeerId: "peer-a",
|
||||
backoffMaxMs: 100,
|
||||
});
|
||||
expect(b.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendBodySchema", () => {
|
||||
it("accepts a canonical body", { timeout: 5000 }, () => {
|
||||
const r = SendBodySchema.safeParse({
|
||||
to: "peer-b",
|
||||
envelope: makeEnvelope(),
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-string envelope id", { timeout: 5000 }, () => {
|
||||
const r = SendBodySchema.safeParse({
|
||||
to: "peer-b",
|
||||
envelope: { ...makeEnvelope(), id: 42 },
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts broadcast targetId", { timeout: 5000 }, () => {
|
||||
const r = SendBodySchema.safeParse({
|
||||
to: "peer-b",
|
||||
envelope: { ...makeEnvelope(), targetId: "*" },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SendResponseSchema", () => {
|
||||
it("accepts { ok: true }", { timeout: 5000 }, () => {
|
||||
expect(SendResponseSchema.safeParse({ ok: true }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts { ok: false, code: 'POLICY' }", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SendResponseSchema.safeParse({ ok: false, code: "POLICY" }).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing ok", { timeout: 5000 }, () => {
|
||||
expect(SendResponseSchema.safeParse({ code: "X" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RecvResponseSchema", () => {
|
||||
it("accepts { envelopes: [...], nextCursor: 'x' }", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
RecvResponseSchema.safeParse({
|
||||
envelopes: [makeEnvelope()],
|
||||
nextCursor: "x",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing nextCursor", { timeout: 5000 }, () => {
|
||||
expect(RecvResponseSchema.safeParse({ envelopes: [] }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an envelope missing required fields", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
RecvResponseSchema.safeParse({
|
||||
envelopes: [{ id: "x" }],
|
||||
nextCursor: "n",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* `HttpsTransport.send` — covers happy path, 4xx, 5xx, malformed
|
||||
* response bodies, ok:false rejections, fetch-throws, and lifecycle
|
||||
* preconditions.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HTTPS_TRANSPORT_CODES,
|
||||
HttpsTransport,
|
||||
HttpsTransportError,
|
||||
} from "../src/index.js";
|
||||
import {
|
||||
type FakeFetchCall,
|
||||
jsonResponse,
|
||||
makeEnvelope,
|
||||
makeFakeFetch,
|
||||
TEST_PEER,
|
||||
} from "./_helpers.js";
|
||||
|
||||
const RELAY = "https://relay.test";
|
||||
|
||||
describe("HttpsTransport.send — wire flow", () => {
|
||||
it("posts to /send with bearer auth and the envelope body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const history: FakeFetchCall[] = [];
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? jsonResponse({ ok: true })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
history,
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
authToken: "secret",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "ignored");
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
|
||||
const sendCall = history.find((c) => c.url === `${RELAY}/send`);
|
||||
expect(sendCall).toBeDefined();
|
||||
expect(sendCall?.method).toBe("POST");
|
||||
expect(sendCall?.headers.authorization).toBe("Bearer secret");
|
||||
const parsed = JSON.parse(sendCall?.body ?? "{}");
|
||||
expect(parsed.to).toBe(TEST_PEER);
|
||||
expect(parsed.envelope.id).toBe("msg-1");
|
||||
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("strips trailing slashes from relayUrl", { timeout: 5000 }, async () => {
|
||||
const history: FakeFetchCall[] = [];
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ ok: true }),
|
||||
history,
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: `${RELAY}//`,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "ignored");
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
const sendCall = history.find((c) => c.url.endsWith("/send"));
|
||||
expect(sendCall?.url).toBe(`${RELAY}/send`);
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("omits the Authorization header when no token is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const history: FakeFetchCall[] = [];
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ ok: true }),
|
||||
history,
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "ignored");
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
const sendCall = history.find((c) => c.url.endsWith("/send"));
|
||||
expect(sendCall?.headers.authorization).toBeUndefined();
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("maps 401 to AUTH_FAILED", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? new Response("nope", { status: 401 })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.AUTH_FAILED,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("maps 503 to RELAY_UNAVAILABLE", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? new Response("down", { status: 503 })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.RELAY_UNAVAILABLE,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("maps 400 (other 4xx) to SEND_FAILED", { timeout: 5000 }, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? new Response("bad", { status: 400 })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.SEND_FAILED,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("treats fetch-throws as SEND_FAILED with cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn: async (url) => {
|
||||
if (String(url).endsWith("/send")) {
|
||||
throw new Error("ECONNRESET");
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "0" });
|
||||
},
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
try {
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
throw new Error("expected SEND_FAILED");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpsTransportError);
|
||||
const e = err as HttpsTransportError;
|
||||
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
|
||||
expect(e.message).toContain("ECONNRESET");
|
||||
expect(e.cause).toBeInstanceOf(Error);
|
||||
}
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("treats fetch-throws of non-Error as SEND_FAILED with stringified cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchSubstitute = async (
|
||||
url: string | URL
|
||||
): Promise<Response> => {
|
||||
if (String(url).endsWith("/send")) {
|
||||
// Throw a non-Error value to exercise the toError(cause)
|
||||
// branch that wraps strings.
|
||||
const literal: unknown = "boom-string";
|
||||
throw literal;
|
||||
}
|
||||
return jsonResponse({ envelopes: [], nextCursor: "0" });
|
||||
};
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn: fetchSubstitute,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
try {
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
throw new Error("expected SEND_FAILED");
|
||||
} catch (err) {
|
||||
const e = err as HttpsTransportError;
|
||||
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
|
||||
expect(e.message).toContain("boom-string");
|
||||
}
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("MALFORMED_RESPONSE when /send returns non-JSON 200", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? new Response("not-json{{", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("MALFORMED_RESPONSE when /send body is unexpected JSON shape", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? jsonResponse({ unexpected: true })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.MALFORMED_RESPONSE,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("SEND_FAILED when relay returns { ok: false, code: 'POLICY' }", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? jsonResponse({ ok: false, code: "POLICY" })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
try {
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
throw new Error("expected");
|
||||
} catch (err) {
|
||||
const e = err as HttpsTransportError;
|
||||
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
|
||||
expect(e.message).toContain("POLICY");
|
||||
}
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("SEND_FAILED when relay returns { ok: false } without a code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: (call) =>
|
||||
call.url.endsWith("/send")
|
||||
? jsonResponse({ ok: false })
|
||||
: jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await t.connect(TEST_PEER, "x");
|
||||
try {
|
||||
await t.send(TEST_PEER, makeEnvelope());
|
||||
throw new Error("expected");
|
||||
} catch (err) {
|
||||
const e = err as HttpsTransportError;
|
||||
expect(e.code).toBe(HTTPS_TRANSPORT_CODES.SEND_FAILED);
|
||||
expect(e.message).toContain("unknown");
|
||||
}
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("PEER_UNKNOWN when sending to a peer that has not connected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
longPollTimeoutMs: 100,
|
||||
});
|
||||
t.start();
|
||||
await expect(
|
||||
t.send("not-connected", makeEnvelope())
|
||||
).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.PEER_UNKNOWN,
|
||||
});
|
||||
await t.stop();
|
||||
});
|
||||
|
||||
it("NOT_CONNECTED when send() is called before start()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ ok: true }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.NOT_CONNECTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("CLOSED when send() is called after stop()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetchFn = makeFakeFetch({
|
||||
responder: () => jsonResponse({ envelopes: [], nextCursor: "0" }),
|
||||
});
|
||||
const t = new HttpsTransport({
|
||||
relayUrl: RELAY,
|
||||
ourPeerId: "peer-a",
|
||||
fetchFn,
|
||||
});
|
||||
t.start();
|
||||
await t.stop();
|
||||
await expect(t.send(TEST_PEER, makeEnvelope())).rejects.toMatchObject({
|
||||
code: HTTPS_TRANSPORT_CODES.CLOSED,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user