test(ws-client): add comprehensive tests for WebSocket client event target

This commit is contained in:
2026-05-08 07:17:47 +00:00
parent 4495c71263
commit ad00e15f91

View File

@@ -7,6 +7,7 @@ type TestEvent = TypedEvent<string, EventEnvelope>;
function createMockWebSocket() { function createMockWebSocket() {
const sent: string[] = []; const sent: string[] = [];
let onmessageHandler: ((event: { data: string }) => void) | null = null; let onmessageHandler: ((event: { data: string }) => void) | null = null;
let oncloseHandler: ((event: { code: number; reason: string }) => void) | null = null;
const ws = { const ws = {
send: vi.fn((data: string) => { send: vi.fn((data: string) => {
@@ -18,12 +19,24 @@ function createMockWebSocket() {
set onmessage(handler: ((event: { data: string }) => void) | null) { set onmessage(handler: ((event: { data: string }) => void) | null) {
onmessageHandler = handler; onmessageHandler = handler;
}, },
get onclose() {
return oncloseHandler;
},
set onclose(handler: ((event: { code: number; reason: string }) => void) | null) {
oncloseHandler = handler;
},
sent, sent,
simulateMessage(data: string) { simulateMessage(data: string) {
if (onmessageHandler) { if (onmessageHandler) {
onmessageHandler({ data }); onmessageHandler({ data });
} }
}, },
simulateClose(code = 1000, reason = "") {
if (oncloseHandler) {
oncloseHandler({ code, reason });
}
onmessageHandler = null;
},
}; };
Object.defineProperty(ws, "onmessage", { Object.defineProperty(ws, "onmessage", {
@@ -35,6 +48,15 @@ function createMockWebSocket() {
}, },
}); });
Object.defineProperty(ws, "onclose", {
get() {
return oncloseHandler;
},
set(handler: ((event: { code: number; reason: string }) => void) | null) {
oncloseHandler = handler;
},
});
return ws; return ws;
} }
@@ -444,6 +466,204 @@ describe("createWebSocketClientEventTarget", () => {
}); });
}); });
describe("topic scoping", () => {
it("forms topic from envelope type and id fields", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("user.action:abc-123", listener);
const envelope: EventEnvelope = {
type: "user.action",
id: "abc-123",
payload: { done: true },
};
ws.simulateMessage(JSON.stringify(envelope));
expect(listener).toHaveBeenCalledTimes(1);
expect((listener.mock.calls[0][0] as TestEvent).type).toBe("user.action:abc-123");
});
it("does not match when type differs even if id is the same", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("user.created:id1", listener);
ws.simulateMessage(JSON.stringify({ type: "user.deleted", id: "id1", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
it("does not match when id differs even if type is the same", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("event:alpha", listener);
ws.simulateMessage(JSON.stringify({ type: "event", id: "beta", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
it("forms topic with undefined id as 'type:undefined' which does not match 'type:'", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("ping:", listener);
ws.simulateMessage(JSON.stringify({ type: "ping", payload: "hello" }));
expect(listener).not.toHaveBeenCalled();
});
});
describe("envelope validation on receive", () => {
it("ignores messages where type is not a string", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
ws.simulateMessage(JSON.stringify({ type: 123, id: "a", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
it("ignores messages where type is undefined", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
ws.simulateMessage(JSON.stringify({ id: "a", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
it("ignores messages where type is null", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
ws.simulateMessage(JSON.stringify({ type: null, id: "a", payload: null }));
expect(listener).not.toHaveBeenCalled();
});
});
describe("reconnection", () => {
it("restores subscriptions on a new event target with a new WebSocket", () => {
const ws1 = createMockWebSocket();
const eventTarget1 = createWebSocketClientEventTarget<TestEvent>(ws1 as any);
const listener1 = vi.fn();
eventTarget1.addEventListener("order.created:ord-1", listener1);
expect(ws1.send).toHaveBeenCalledTimes(1);
ws1.simulateClose();
const ws2 = createMockWebSocket();
const eventTarget2 = createWebSocketClientEventTarget<TestEvent>(ws2 as any);
const listener2 = vi.fn();
eventTarget2.addEventListener("order.created:ord-1", listener2);
expect(ws2.send).toHaveBeenCalledTimes(1);
expect(ws2.send).toHaveBeenCalledWith(
JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "order.created:ord-1" } }),
);
const envelope: EventEnvelope = { type: "order.created", id: "ord-1", payload: { item: "book" } };
ws2.simulateMessage(JSON.stringify(envelope));
expect(listener2).toHaveBeenCalledTimes(1);
expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
});
it("does not receive messages on old connection after reconnect", () => {
const ws1 = createMockWebSocket();
const eventTarget1 = createWebSocketClientEventTarget<TestEvent>(ws1 as any);
const listener1 = vi.fn();
eventTarget1.addEventListener("chat.msg:1", listener1);
ws1.simulateClose();
const ws2 = createMockWebSocket();
const eventTarget2 = createWebSocketClientEventTarget<TestEvent>(ws2 as any);
const listener2 = vi.fn();
eventTarget2.addEventListener("chat.msg:1", listener2);
ws1.simulateMessage(JSON.stringify({ type: "chat.msg", id: "1", payload: "stale" }));
expect(listener1).not.toHaveBeenCalled();
expect(listener2).not.toHaveBeenCalled();
});
});
describe("connection close", () => {
it("does not send __unsubscribe on close since lifecycle is caller-managed", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const listener = vi.fn();
eventTarget.addEventListener("topic:a", listener);
expect(ws.send).toHaveBeenCalledTimes(1);
ws.simulateClose();
expect(ws.send).toHaveBeenCalledTimes(1);
});
});
describe("dispatchEvent (send path) edge cases", () => {
it("sends envelope with null payload", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const event = new CustomEvent("notify:1", {
detail: { type: "notify", id: "1", payload: null },
}) as TestEvent;
eventTarget.dispatchEvent(event);
expect(ws.send).toHaveBeenCalledTimes(1);
const sent = JSON.parse(ws.sent[0]);
expect(sent.type).toBe("notify");
expect(sent.id).toBe("1");
expect(sent.payload).toBeNull();
});
it("sends multiple events in order", () => {
const ws = createMockWebSocket();
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
const event1 = new CustomEvent("a:1", {
detail: { type: "a", id: "1", payload: "first" },
}) as TestEvent;
const event2 = new CustomEvent("b:2", {
detail: { type: "b", id: "2", payload: "second" },
}) as TestEvent;
eventTarget.dispatchEvent(event1);
eventTarget.dispatchEvent(event2);
expect(ws.sent).toHaveLength(2);
expect(JSON.parse(ws.sent[0])).toEqual({ type: "a", id: "1", payload: "first" });
expect(JSON.parse(ws.sent[1])).toEqual({ type: "b", id: "2", payload: "second" });
});
});
describe("subscription reference counting", () => { describe("subscription reference counting", () => {
it("re-sends __subscribe after all listeners removed and a new one added", () => { it("re-sends __subscribe after all listeners removed and a new one added", () => {
const ws = createMockWebSocket(); const ws = createMockWebSocket();