feat(redis-adapter-tests): add comprehensive tests for Redis event target adapter
17 tests covering: - dispatchEvent publish path (correct channel, serialized envelope, returns true) - addEventListener subscribe path (subscribes to Redis, dispatches to local listeners, ignores unknown channels) - removeEventListener unsubscribe path (unsubscribes when last listener removed, keeps subscription when listeners remain) - Topic scoping (type:id strings as Redis channel names) - EventEnvelope round-trip serialization (full envelope, null payload) - Multiple listeners (single Redis subscribe, all listeners receive messages) - Custom serializer (custom stringify/parse, non-JSON round-trip) - EventListenerObject support (handleEvent) Uses manual Redis mock (no external dependencies needed).
This commit is contained in:
435
test/event-target-redis.test.ts
Normal file
435
test/event-target-redis.test.ts
Normal file
@@ -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<string, EventEnvelope>;
|
||||
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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<TestEvent>({
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user