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() {
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user