diff --git a/test/event-target-websocket-client.test.ts b/test/event-target-websocket-client.test.ts index 8a196f4..a6a0f79 100644 --- a/test/event-target-websocket-client.test.ts +++ b/test/event-target-websocket-client.test.ts @@ -7,6 +7,7 @@ type TestEvent = TypedEvent; 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(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(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(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(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(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(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(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(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(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(ws1 as any); + + const listener1 = vi.fn(); + eventTarget1.addEventListener("chat.msg:1", listener1); + + ws1.simulateClose(); + + const ws2 = createMockWebSocket(); + const eventTarget2 = createWebSocketClientEventTarget(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(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(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(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();