Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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");
}

View File

@@ -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;
}
});
});

View File

@@ -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);
}
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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,
});
});
});