Files
2026-07-12 15:55:18 +05:30

162 lines
5.8 KiB
TypeScript

import { test, expect } from "bun:test";
import {
defineRoom,
isRoomDefinition,
createRealtimeRegistry,
type RawSocket,
} from "../src/index.ts";
interface MockSocket extends RawSocket {
sent: Record<string, unknown>[];
}
function mockSocket(): MockSocket {
const sent: Record<string, unknown>[] = [];
return {
sent,
send(data: string) {
sent.push(JSON.parse(data) as Record<string, unknown>);
},
close() {},
};
}
test("defineRoom marks a room definition", () => {
expect(isRoomDefinition(defineRoom({}))).toBe(true);
expect(isRoomDefinition({})).toBe(false);
expect(isRoomDefinition(null)).toBe(false);
});
test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
const events: string[] = [];
const def = defineRoom({
onConnect(c) {
events.push("connect");
c.broadcast({ type: "join" }); // others only
},
onMessage(c, m) {
events.push("message");
c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
},
onLeave(c) {
events.push("leave");
c.broadcast({ type: "left" });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/r/x", def });
await reg.open(b, { room: "/r/x", def });
expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join
await reg.message(a, JSON.stringify({ text: "hi" }));
expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);
await reg.close(b);
expect(a.sent.some((m) => m.type === "left")).toBe(true);
expect(reg.size()).toBe(1);
expect(events).toEqual(["connect", "connect", "message", "leave"]);
});
test("to(connectionId) and toUser(user|users) target precisely", async () => {
const ids: Record<string, string> = {};
const def = defineRoom({
onConnect(c) {
c.user = c.query.as; // identify by ?as=
ids[c.query.as!] = c.id;
},
onMessage(c, m) {
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
},
});
const reg = createRealtimeRegistry();
const alice = mockSocket();
const bob = mockSocket();
const carol = mockSocket();
await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
await reg.open(carol, { room: "/r", def, query: { as: "carol" } });
// single user
await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
expect(carol.sent.some((m) => m.type === "dm")).toBe(false);
// selected users
await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
expect(carol.sent.some((m) => m.text === "both")).toBe(true);
// by connection id
await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
});
test("rooms are isolated from each other", async () => {
const def = defineRoom({
onMessage(c, m) {
c.room.broadcast({ type: "x", text: m.text });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
await reg.open(b, { room: "/room/2", def });
await reg.message(a, JSON.stringify({ text: "one" }));
expect(a.sent.some((m) => m.text === "one")).toBe(true);
expect(b.sent.length).toBe(0); // different room, untouched
});
test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
const regA = createRealtimeRegistry();
const regB = createRealtimeRegistry();
// A shared bus: each instance delivers the other's published envelopes.
regA.setBridge({ publish: (env) => regB.deliver(env) });
regB.setBridge({ publish: (env) => regA.deliver(env) });
const def = defineRoom({
onConnect(c) {
c.user = c.query.as;
},
onMessage(c, m) {
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
else c.room.broadcast({ type: "x", text: m.text });
},
});
const a = mockSocket();
const b = mockSocket();
await regA.open(a, { room: "/r", def, query: { as: "alice" } });
await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance
// broadcast from A reaches B through the bridge
await regA.message(a, JSON.stringify({ text: "cross-instance" }));
expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);
// toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
const aliceDms = a.sent.filter((m) => m.type === "dm").length;
expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
});
test("room.state and count() track the live room", async () => {
const def = defineRoom({
onConnect(c) {
c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
},
});
const reg = createRealtimeRegistry();
const a = mockSocket();
const b = mockSocket();
await reg.open(a, { room: "/r", def });
await reg.open(b, { room: "/r", def });
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
});