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; }), off: vi.fn((event: string, callback: (channel: string, message: string) => void) => { if (event === "message" && messageListener === callback) { messageListener = null; } }), 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("channel prefix", () => { it("publishes to prefixed channel when prefix is set", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); 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).toHaveBeenCalledWith( "alk:events:call.responded:uuid-123", expect.any(String), ); }); it("subscribes to prefixed channel when prefix is set", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); const listener = vi.fn(); eventTarget.addEventListener("call.responded:uuid-123", listener); expect(subscribeClient.subscribe).toHaveBeenCalledWith("alk:events:call.responded:uuid-123"); }); it("unsubscribes from prefixed channel when prefix is set", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); const listener = vi.fn(); eventTarget.addEventListener("call.responded:uuid-123", listener); eventTarget.removeEventListener("call.responded:uuid-123", listener); expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("alk:events:call.responded:uuid-123"); }); it("delivers messages on prefixed channels to listeners", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); const listener = vi.fn(); eventTarget.addEventListener("call.responded:uuid-123", listener); const envelope: EventEnvelope = { type: "call.responded", id: "uuid-123", payload: { status: "ok" }, }; subscribeClient.simulateMessage("alk:events:call.responded:uuid-123", JSON.stringify(envelope)); expect(listener).toHaveBeenCalledTimes(1); expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); }); it("ignores messages on non-prefixed channels when prefix is set", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); const listener = vi.fn(); eventTarget.addEventListener("call.responded:uuid-123", listener); subscribeClient.simulateMessage("call.responded:uuid-123", JSON.stringify({ type: "call.responded", id: "uuid-123", payload: null })); expect(listener).not.toHaveBeenCalled(); }); it("defaults prefix to empty string", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, }); const event = new CustomEvent("test:1", { detail: { type: "test", id: "1", payload: null }, }) as TestEvent; eventTarget.dispatchEvent(event); expect(publishClient.publish).toHaveBeenCalledWith("test:1", expect.any(String)); }); }); describe("error handling", () => { it("skips messages that fail to parse and logs a warning", () => { 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); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); subscribeClient.simulateMessage("topic:a", "not valid json{{"); expect(listener).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( 'Failed to parse message on channel "topic:a": not valid json{{', ); warnSpy.mockRestore(); }); it("continues delivering valid messages after a parse error", () => { 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); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); subscribeClient.simulateMessage("topic:a", "broken{{{"); expect(listener).not.toHaveBeenCalled(); const envelope: EventEnvelope = { type: "topic", id: "a", payload: "good" }; subscribeClient.simulateMessage("topic:a", JSON.stringify(envelope)); expect(listener).toHaveBeenCalledTimes(1); expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope); warnSpy.mockRestore(); }); }); 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"); }); }); describe("close()", () => { it("unsubscribes from all active channels", () => { 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:a", listener1); eventTarget.addEventListener("topic:b", listener2); eventTarget.close(); expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:a"); expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("topic:b"); }); it("removes the message listener from subscribeClient", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, }); eventTarget.close(); expect(subscribeClient.off).toHaveBeenCalledWith("message", expect.any(Function)); }); it("does not receive messages after close", () => { 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); eventTarget.close(); const envelope: EventEnvelope = { type: "topic", id: "a", payload: "hello" }; subscribeClient.simulateMessage("topic:a", JSON.stringify(envelope)); expect(listener).not.toHaveBeenCalled(); }); it("is idempotent", () => { 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); eventTarget.close(); eventTarget.close(); expect(subscribeClient.unsubscribe).toHaveBeenCalledTimes(1); }); it("handles close with no subscriptions", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, }); expect(() => eventTarget.close()).not.toThrow(); }); it("unsubscribes from prefixed channels correctly", () => { const publishClient = createMockRedis(); const subscribeClient = createMockRedis(); const eventTarget = createRedisEventTarget({ publishClient: publishClient as any, subscribeClient: subscribeClient as any, prefix: "alk:events:", }); const listener = vi.fn(); eventTarget.addEventListener("topic:a", listener); eventTarget.close(); expect(subscribeClient.unsubscribe).toHaveBeenCalledWith("alk:events:topic:a"); }); }); });