test(ws-client): add comprehensive tests for WebSocket client event target
This commit is contained in:
@@ -7,6 +7,7 @@ type TestEvent = TypedEvent<string, EventEnvelope>;
|
||||
function createMockWebSocket() {
|
||||
const sent: string[] = [];
|
||||
let onmessageHandler: ((event: { data: string }) => void) | null = null;
|
||||
let oncloseHandler: ((event: { code: number; reason: string }) => void) | null = null;
|
||||
|
||||
const ws = {
|
||||
send: vi.fn((data: string) => {
|
||||
@@ -18,12 +19,24 @@ function createMockWebSocket() {
|
||||
set onmessage(handler: ((event: { data: string }) => void) | null) {
|
||||
onmessageHandler = handler;
|
||||
},
|
||||
get onclose() {
|
||||
return oncloseHandler;
|
||||
},
|
||||
set onclose(handler: ((event: { code: number; reason: string }) => void) | null) {
|
||||
oncloseHandler = handler;
|
||||
},
|
||||
sent,
|
||||
simulateMessage(data: string) {
|
||||
if (onmessageHandler) {
|
||||
onmessageHandler({ data });
|
||||
}
|
||||
},
|
||||
simulateClose(code = 1000, reason = "") {
|
||||
if (oncloseHandler) {
|
||||
oncloseHandler({ code, reason });
|
||||
}
|
||||
onmessageHandler = null;
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
it("re-sends __subscribe after all listeners removed and a new one added", () => {
|
||||
const ws = createMockWebSocket();
|
||||
|
||||
Reference in New Issue
Block a user