Files
2026-05-27 23:47:53 +01:00

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