diff --git a/test/event-target-redis.test.ts b/test/event-target-redis.test.ts new file mode 100644 index 0000000..a4e500f --- /dev/null +++ b/test/event-target-redis.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, vi } from "vitest"; +import { createRedisEventTarget } from "../src/event-target-redis.js"; +import type { EventEnvelope, TypedEvent } from "../src/types.js"; + +type TestEvent = TypedEvent; + +function createMockRedis() { + const publications: { channel: string; message: string }[] = []; + const subscriptions: { channel: string }[] = []; + const unsubscriptions: { channel: string }[] = []; + let messageListener: ((channel: string, message: string) => void) | null = null; + + return { + publish: vi.fn((channel: string, message: string) => { + publications.push({ channel, message }); + }), + subscribe: vi.fn((channel: string) => { + subscriptions.push({ channel }); + }), + unsubscribe: vi.fn((channel: string) => { + unsubscriptions.push({ channel }); + }), + on: vi.fn((event: string, callback: (channel: string, message: string) => void) => { + if (event === "message") { + messageListener = callback; + } + return {} as any; + }), + publications, + subscriptions, + unsubscriptions, + simulateMessage(channel: string, message: string) { + if (messageListener) { + messageListener(channel, message); + } + }, + }; +} + +describe("createRedisEventTarget", () => { + describe("dispatchEvent (publish path)", () => { + it("publishes to Redis with correct channel name matching event type", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const event = new CustomEvent("call.responded:uuid-123", { + detail: { type: "call.responded", id: "uuid-123", payload: { status: "ok" } }, + }) as TestEvent; + + eventTarget.dispatchEvent(event); + + expect(publishClient.publish).toHaveBeenCalledTimes(1); + expect(publishClient.publish).toHaveBeenCalledWith( + "call.responded:uuid-123", + JSON.stringify({ type: "call.responded", id: "uuid-123", payload: { status: "ok" } }), + ); + }); + + it("serializes the envelope detail using the default JSON serializer", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const envelope: EventEnvelope<"user.joined", { name: string }> = { + type: "user.joined", + id: "user-42", + payload: { name: "Alice" }, + }; + + const event = new CustomEvent("user.joined:user-42", { + detail: envelope, + }) as TestEvent; + + eventTarget.dispatchEvent(event); + + expect(publishClient.publish).toHaveBeenCalledTimes(1); + const publishedMessage = publishClient.publications[0].message; + expect(JSON.parse(publishedMessage)).toEqual(envelope); + }); + + it("returns true from dispatchEvent", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const event = new CustomEvent("test.event:id1", { + detail: { type: "test.event", id: "id1", payload: null }, + }) as TestEvent; + + const result = eventTarget.dispatchEvent(event); + expect(result).toBe(true); + }); + }); + + describe("addEventListener (subscribe path)", () => { + it("subscribes to Redis on the topic when first listener is added", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("message.sent:msg1", listener); + + expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1); + expect(subscribeClient.subscribe).toHaveBeenCalledWith("message.sent:msg1"); + }); + + it("dispatches deserialized messages to local listeners", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("message.sent:msg1", listener); + + const envelope: EventEnvelope = { + type: "message.sent", + id: "msg1", + payload: "hello world", + }; + subscribeClient.simulateMessage("message.sent:msg1", JSON.stringify(envelope)); + + expect(listener).toHaveBeenCalledTimes(1); + const receivedEvent = listener.mock.calls[0][0] as TestEvent; + expect(receivedEvent.type).toBe("message.sent:msg1"); + expect(receivedEvent.detail).toEqual(envelope); + }); + + it("ignores messages on channels with no registered listeners", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("topic:a", listener); + + subscribeClient.simulateMessage("topic:b", JSON.stringify({ type: "topic", id: "b", payload: null })); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe("removeEventListener (unsubscribe path)", () => { + it("unsubscribes from Redis when the last listener for a topic is removed", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("topic:a", listener); + + expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1); + + eventTarget.removeEventListener("topic:a", listener); + + expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1); + expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:a"); + }); + + it("does not unsubscribe from Redis while other listeners remain on the same topic", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener1 = vi.fn(); + const listener2 = vi.fn(); + eventTarget.addEventListener("event:type1", listener1); + eventTarget.addEventListener("event:type1", listener2); + + expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1); + + eventTarget.removeEventListener("event:type1", listener1); + + expect(subscribeClient.unsubscribe).not.toHaveBeenCalled(); + + eventTarget.removeEventListener("event:type1", listener2); + + expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1); + expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("event:type1"); + }); + }); + + describe("topic scoping", () => { + it("uses type:id strings as Redis channel names", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("call.responded:uuid-456", listener); + + expect(subscribeClient.subscribe).toHaveBeenCalledWith("call.responded:uuid-456"); + + const event = new CustomEvent("call.responded:uuid-456", { + detail: { type: "call.responded", id: "uuid-456", payload: { ok: true } }, + }) as TestEvent; + + eventTarget.dispatchEvent(event); + + expect(publishClient.publications[0].channel).toBe("call.responded:uuid-456"); + }); + }); + + describe("EventEnvelope serialization round-trip", () => { + it("round-trips full { type, id, payload } envelope through JSON", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("user.joined:user-99", listener); + + const originalEnvelope: EventEnvelope<"user.joined", { name: string; role: string }> = { + type: "user.joined", + id: "user-99", + payload: { name: "Bob", role: "admin" }, + }; + + const event = new CustomEvent("user.joined:user-99", { + detail: originalEnvelope, + }) as TestEvent; + + eventTarget.dispatchEvent(event); + + const publishedMessage = publishClient.publications[0].message; + subscribeClient.simulateMessage("user.joined:user-99", publishedMessage); + + expect(listener).toHaveBeenCalledTimes(1); + const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope; + expect(receivedDetail).toEqual(originalEnvelope); + expect(receivedDetail.type).toBe("user.joined"); + expect(receivedDetail.id).toBe("user-99"); + expect(receivedDetail.payload).toEqual({ name: "Bob", role: "admin" }); + }); + + it("round-trips envelope with null payload", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("ping:1", listener); + + const envelope: EventEnvelope = { type: "ping", id: "1", payload: null }; + subscribeClient.simulateMessage("ping:1", JSON.stringify(envelope)); + + expect(listener).toHaveBeenCalledTimes(1); + const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope; + expect(receivedDetail).toEqual(envelope); + }); + }); + + describe("multiple listeners on the same topic", () => { + it("subscribes to Redis only once when multiple listeners are added to the same topic", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener1 = vi.fn(); + const listener2 = vi.fn(); + const listener3 = vi.fn(); + + eventTarget.addEventListener("topic:x", listener1); + eventTarget.addEventListener("topic:x", listener2); + eventTarget.addEventListener("topic:x", listener3); + + expect(subscribeClient.subscribe).toHaveBeenCalledTimes(1); + expect(subscribeClient.subscribe).toHaveBeenCalledWith("topic:x"); + }); + + it("delivers messages to all listeners on the same topic", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const listener1 = vi.fn(); + const listener2 = vi.fn(); + eventTarget.addEventListener("topic:x", listener1); + eventTarget.addEventListener("topic:x", listener2); + + const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" }; + subscribeClient.simulateMessage("topic:x", JSON.stringify(envelope)); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + expect((listener1.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); + expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); + }); + }); + + describe("custom serializer", () => { + it("uses custom serializer for stringify and parse", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const customSerializer = { + stringify: vi.fn((value: unknown) => JSON.stringify(value)), + parse: vi.fn((text: string) => JSON.parse(text)), + }; + + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + serializer: customSerializer, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("custom:event1", listener); + + const envelope: EventEnvelope = { type: "custom", id: "event1", payload: { key: "value" } }; + const event = new CustomEvent("custom:event1", { detail: envelope }) as TestEvent; + eventTarget.dispatchEvent(event); + + expect(customSerializer.stringify).toHaveBeenCalledTimes(1); + expect(customSerializer.stringify).toHaveBeenCalledWith(envelope); + + const publishedMessage = publishClient.publications[0].message; + subscribeClient.simulateMessage("custom:event1", publishedMessage); + + expect(customSerializer.parse).toHaveBeenCalledTimes(1); + expect(customSerializer.parse).toHaveBeenCalledWith(publishedMessage); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("allows non-JSON serializers to round-trip envelopes", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + + const prefixSerializer = { + stringify: (value: unknown) => `PREFIX:${JSON.stringify(value)}`, + parse: (text: string) => JSON.parse(text.slice(7)), + }; + + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + serializer: prefixSerializer, + }); + + const listener = vi.fn(); + eventTarget.addEventListener("test:str", listener); + + const envelope: EventEnvelope = { type: "test", id: "str", payload: 42 }; + const event = new CustomEvent("test:str", { detail: envelope }) as TestEvent; + eventTarget.dispatchEvent(event); + + const publishedMessage = publishClient.publications[0].message; + expect(publishedMessage).toBe(`PREFIX:${JSON.stringify(envelope)}`); + + subscribeClient.simulateMessage("test:str", publishedMessage); + + expect(listener).toHaveBeenCalledTimes(1); + const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope; + expect(receivedDetail).toEqual(envelope); + }); + }); + + describe("EventListenerObject support", () => { + it("addEventListener accepts EventListenerObject with handleEvent", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const handleEvent = vi.fn(); + const listenerObject = { handleEvent }; + + eventTarget.addEventListener("obj:test", listenerObject); + + const envelope: EventEnvelope = { type: "obj", id: "test", payload: true }; + subscribeClient.simulateMessage("obj:test", JSON.stringify(envelope)); + + expect(handleEvent).toHaveBeenCalledTimes(1); + }); + + it("removeEventListener accepts EventListenerObject with handleEvent", () => { + const publishClient = createMockRedis(); + const subscribeClient = createMockRedis(); + const eventTarget = createRedisEventTarget({ + publishClient: publishClient as any, + subscribeClient: subscribeClient as any, + }); + + const handleEvent = vi.fn(); + const listenerObject = { handleEvent }; + + eventTarget.addEventListener("obj:test2", listenerObject); + expect(subscribeClient.subscribe).toHaveBeenCalledWith("obj:test2"); + + eventTarget.removeEventListener("obj:test2", listenerObject); + expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("obj:test2"); + }); + }); +}); \ No newline at end of file