316 lines
9.9 KiB
TypeScript
316 lines
9.9 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
FakeWsServer,
|
|
makeFakeWsServerFactory,
|
|
} from "../../tests/setup/fake-ws-gateway.js";
|
|
import { PresenceTracker } from "../presence/presence.js";
|
|
import { ChannelManager } from "./channel-manager.js";
|
|
import { connectClient, disconnectClient } from "./client-lifecycle.js";
|
|
import { WebSocketGateway } from "./gateway.js";
|
|
import {
|
|
buildEmit,
|
|
buildFirePresence,
|
|
buildRequireAuthCtx,
|
|
} from "./gateway-deps.js";
|
|
import { createHeartbeat } from "./heartbeat.js";
|
|
import { runAuthHandshake } from "./lifecycle.js";
|
|
import { firePresence } from "./presence-fire.js";
|
|
import {
|
|
deliverPresenceWebhook,
|
|
type PresenceWebhookEvent,
|
|
} from "./presence-webhook.js";
|
|
|
|
describe("ChannelManager direct surface", () => {
|
|
it("size + getOrCreate idempotence + getChannel + publish on unknown + size", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const mgr = new ChannelManager();
|
|
expect(mgr.size).toBe(0);
|
|
const c1 = mgr.getOrCreateChannel("evt");
|
|
const c2 = mgr.getOrCreateChannel("evt");
|
|
expect(c1).toBe(c2);
|
|
expect(mgr.getChannel("evt")).toBe(c1);
|
|
expect(mgr.getChannel("nope")).toBeUndefined();
|
|
expect(mgr.size).toBe(1);
|
|
expect(mgr.publish("missing", "alice", "topic", null, 1)).toBeNull();
|
|
mgr.removeClient("ghost");
|
|
mgr.clear();
|
|
expect(mgr.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("heartbeat sweep", () => {
|
|
it("sweeps timed-out clients", { timeout: 5000 }, async () => {
|
|
let now = 0;
|
|
const presence = new PresenceTracker(10, () => now);
|
|
presence.track("a");
|
|
const out: string[] = [];
|
|
const hb = createHeartbeat(
|
|
presence,
|
|
5,
|
|
(id) => out.push(id),
|
|
() => true
|
|
);
|
|
hb.start();
|
|
hb.start(); // idempotent
|
|
now = 1_000;
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
hb.stop();
|
|
hb.stop(); // idempotent
|
|
expect(out).toContain("a");
|
|
});
|
|
|
|
it("skips sweep when disabled", { timeout: 5000 }, async () => {
|
|
let now = 0;
|
|
const presence = new PresenceTracker(10, () => now);
|
|
presence.track("a");
|
|
const out: string[] = [];
|
|
const hb = createHeartbeat(
|
|
presence,
|
|
5,
|
|
(id) => out.push(id),
|
|
() => false
|
|
);
|
|
hb.start();
|
|
now = 1_000;
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
hb.stop();
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("firePresence + presence-webhook branch coverage", () => {
|
|
it("firePresence with no bus and no webhook is a no-op", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await firePresence(
|
|
{ clock: () => 0 },
|
|
"join",
|
|
"a",
|
|
undefined,
|
|
"online"
|
|
);
|
|
});
|
|
|
|
it("firePresence with bus that throws is swallowed", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const bus = {
|
|
emit: () => {
|
|
throw new Error("bus down");
|
|
},
|
|
};
|
|
await firePresence(
|
|
{ bus: bus as never, clock: () => 0 },
|
|
"leave",
|
|
"a",
|
|
undefined,
|
|
"offline"
|
|
);
|
|
});
|
|
|
|
it("deliverPresenceWebhook filters by events allowlist", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let called = false;
|
|
const originalFetch = globalThis.fetch;
|
|
(globalThis as { fetch: typeof fetch }).fetch = (async () => {
|
|
called = true;
|
|
return new Response(null, { status: 200 });
|
|
}) as typeof fetch;
|
|
try {
|
|
const ev: PresenceWebhookEvent = {
|
|
kind: "presence:update",
|
|
clientId: "a",
|
|
roomId: undefined,
|
|
status: "away",
|
|
at: 0,
|
|
};
|
|
await deliverPresenceWebhook(
|
|
{
|
|
url: "https://hooks.example.com",
|
|
events: ["presence:join"],
|
|
},
|
|
ev
|
|
);
|
|
expect(called).toBe(false);
|
|
} finally {
|
|
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("deliverPresenceWebhook routes errors through onError", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const errors: unknown[] = [];
|
|
const originalFetch = globalThis.fetch;
|
|
(globalThis as { fetch: typeof fetch }).fetch = (async () => {
|
|
throw new Error("net");
|
|
}) as typeof fetch;
|
|
try {
|
|
await deliverPresenceWebhook(
|
|
{ url: "https://hooks.example.com" },
|
|
{
|
|
kind: "presence:join",
|
|
clientId: "a",
|
|
roomId: undefined,
|
|
status: "online",
|
|
at: 0,
|
|
},
|
|
(e) => errors.push(e)
|
|
);
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
} finally {
|
|
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("client-lifecycle branch coverage", () => {
|
|
it("connectClient with enablePresence:false skips presence tracker", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const presence = new PresenceTracker(100);
|
|
const deps = {
|
|
clients: new Map(),
|
|
authContexts: new Map(),
|
|
rooms: new Map(),
|
|
channelMgr: new ChannelManager(),
|
|
presence,
|
|
enablePresence: false,
|
|
clock: () => 0,
|
|
emit: () => {},
|
|
firePresence: async () => {},
|
|
};
|
|
connectClient(deps, "a");
|
|
expect(presence.size).toBe(0);
|
|
expect(disconnectClient(deps, "a")).toBe(true);
|
|
expect(disconnectClient(deps, "a")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("gateway-deps + buildRequireAuthCtx fallback", () => {
|
|
it("synthetic auth ctx is created on miss", { timeout: 5000 }, () => {
|
|
const requireAuthCtx = buildRequireAuthCtx({
|
|
state: {
|
|
clients: new Map(),
|
|
rooms: new Map(),
|
|
channelMgr: new ChannelManager(),
|
|
presence: new PresenceTracker(100),
|
|
wsServer: undefined,
|
|
started: false,
|
|
disposed: false,
|
|
startedAt: undefined,
|
|
boundAddress: undefined,
|
|
boundPort: undefined,
|
|
},
|
|
resolved: {} as never,
|
|
clock: () => 0,
|
|
authContexts: new Map(),
|
|
idempotency: {} as never,
|
|
});
|
|
const ctx = requireAuthCtx("ghost");
|
|
expect(ctx.clientId).toBe("ghost");
|
|
expect(ctx.principal).toBeUndefined();
|
|
// Second call retrieves the synthetic
|
|
expect(requireAuthCtx("ghost")).toBe(ctx);
|
|
});
|
|
});
|
|
|
|
describe("runAuthHandshake fallback ctx.clientId", () => {
|
|
it("synthesises clientId when verifier returns empty", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const ctx = await runAuthHandshake(
|
|
{
|
|
authVerifier: async () =>
|
|
({
|
|
clientId: "",
|
|
principal: undefined,
|
|
tenantId: undefined,
|
|
scopes: [],
|
|
metadata: {},
|
|
}) as never,
|
|
auditLogger: () => {},
|
|
} as never,
|
|
"sock-x",
|
|
{ headers: {}, remoteAddress: undefined, url: "/", query: {} },
|
|
0
|
|
);
|
|
expect(ctx.clientId).toBe("sock-x");
|
|
});
|
|
});
|
|
|
|
describe("buildEmit no-op without bus + FakeWsServer extras", () => {
|
|
it("emit is a no-op when bus is undefined", { timeout: 5000 }, () => {
|
|
const emit = buildEmit({
|
|
state: {} as never,
|
|
resolved: { bus: undefined } as never,
|
|
clock: () => 0,
|
|
authContexts: new Map(),
|
|
idempotency: {} as never,
|
|
});
|
|
// Should not throw, no observable side effect.
|
|
emit("ws_gateway:stopped", { at: 0 });
|
|
});
|
|
|
|
it("FakeWsServer socket.close, message, getSocket", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const server = new FakeWsServer();
|
|
server.callbacks = {
|
|
onConnection: () => {},
|
|
onMessage: (id, payload) => {
|
|
expect(id).toBe("a");
|
|
expect(payload).toBe("p");
|
|
},
|
|
onClose: () => {},
|
|
onError: () => {},
|
|
};
|
|
const s = server.connect("a");
|
|
s.send("hi");
|
|
s.close();
|
|
s.close(); // idempotent
|
|
server.message("a", "p");
|
|
expect(server.getSocket("a")).toBeUndefined();
|
|
expect(server.socketCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("buildFirePresence branch coverage", () => {
|
|
it("fire with no webhook short-circuits cleanly", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fire = buildFirePresence({
|
|
state: {} as never,
|
|
resolved: { bus: undefined, presenceWebhook: undefined } as never,
|
|
clock: () => 0,
|
|
authContexts: new Map(),
|
|
idempotency: {} as never,
|
|
});
|
|
await fire("update", "a", undefined, "online");
|
|
});
|
|
});
|
|
|
|
describe("WebSocketGateway disabled-presence path", () => {
|
|
it("connectClient does not track presence when disabled", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const { factory } = makeFakeWsServerFactory();
|
|
const gw = new WebSocketGateway({
|
|
port: 0,
|
|
wsServerFactory: factory,
|
|
enablePresence: false,
|
|
});
|
|
gw.connectClient("a");
|
|
expect(gw.getPresenceTracker().size).toBe(0);
|
|
gw.disconnectClient("a");
|
|
});
|
|
|
|
it("config getter returns the resolved config", { timeout: 5000 }, () => {
|
|
const { factory } = makeFakeWsServerFactory();
|
|
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
|
expect(gw.config.maxRooms).toBe(100);
|
|
});
|
|
});
|