@uploadista/event-broadcaster-redis 0.0.13-beta.4 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -8
- package/tests/redis-event-broadcaster.test.ts +322 -0
- package/tests/utils/mock-redis.ts +58 -0
- package/vitest.config.ts +39 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uploadista/event-broadcaster-redis",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.13
|
|
4
|
+
"version": "0.0.13",
|
|
5
5
|
"description": "Redis event broadcaster for Uploadista",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Uploadista",
|
|
@@ -14,19 +14,27 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
17
|
+
"effect": "3.19.3",
|
|
18
|
+
"@uploadista/core": "0.0.13"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@redis/client": ">=5.8.0 <6.0.0"
|
|
20
22
|
},
|
|
21
23
|
"devDependencies": {
|
|
22
|
-
"@
|
|
23
|
-
"
|
|
24
|
-
"@
|
|
24
|
+
"@effect/vitest": "0.27.0",
|
|
25
|
+
"@redis/client": "5.9.0",
|
|
26
|
+
"@types/node": "24.10.1",
|
|
27
|
+
"tsdown": "0.16.3",
|
|
28
|
+
"vitest": "4.0.8",
|
|
29
|
+
"@uploadista/typescript-config": "0.0.13"
|
|
25
30
|
},
|
|
26
31
|
"scripts": {
|
|
27
32
|
"build": "tsdown",
|
|
28
33
|
"format": "biome format --write ./src",
|
|
29
34
|
"lint": "biome lint --write ./src",
|
|
30
|
-
"check": "biome check --write ./src"
|
|
35
|
+
"check": "biome check --write ./src",
|
|
36
|
+
"test": "vitest",
|
|
37
|
+
"test:run": "vitest run",
|
|
38
|
+
"test:watch": "vitest watch"
|
|
31
39
|
}
|
|
32
40
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createRedisEventBroadcaster } from "../src/redis-event-broadcaster";
|
|
4
|
+
import {
|
|
5
|
+
createMockRedisClient,
|
|
6
|
+
mockRedisClientAsType,
|
|
7
|
+
} from "./utils/mock-redis";
|
|
8
|
+
|
|
9
|
+
describe("Redis Event Broadcaster", () => {
|
|
10
|
+
describe("Basic Operations", () => {
|
|
11
|
+
it("should call redis.publish with correct channel and message", async () => {
|
|
12
|
+
const mockRedis = createMockRedisClient();
|
|
13
|
+
const mockSubscriber = createMockRedisClient();
|
|
14
|
+
|
|
15
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
16
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
17
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await Effect.runPromise(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
yield* broadcaster.publish("test-channel", "test-message");
|
|
23
|
+
|
|
24
|
+
expect(mockRedis.publish).toHaveBeenCalledWith(
|
|
25
|
+
"test-channel",
|
|
26
|
+
"test-message",
|
|
27
|
+
);
|
|
28
|
+
expect(mockRedis.publish).toHaveBeenCalledTimes(1);
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should return void on successful publish", async () => {
|
|
34
|
+
const mockRedis = createMockRedisClient();
|
|
35
|
+
const mockSubscriber = createMockRedisClient();
|
|
36
|
+
|
|
37
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
38
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
39
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await Effect.runPromise(
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const result = yield* broadcaster.publish("channel", "message");
|
|
45
|
+
expect(result).toBeUndefined();
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should call subscriberRedis.subscribe with correct parameters", async () => {
|
|
51
|
+
const mockRedis = createMockRedisClient();
|
|
52
|
+
const mockSubscriber = createMockRedisClient();
|
|
53
|
+
|
|
54
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
55
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
56
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await Effect.runPromise(
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const handler = vi.fn();
|
|
62
|
+
yield* broadcaster.subscribe("my-channel", handler);
|
|
63
|
+
|
|
64
|
+
expect(mockSubscriber.subscribe).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(mockSubscriber.subscribe).toHaveBeenCalledWith(
|
|
66
|
+
"my-channel",
|
|
67
|
+
expect.any(Function),
|
|
68
|
+
);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should invoke handler when message arrives", async () => {
|
|
74
|
+
const mockRedis = createMockRedisClient();
|
|
75
|
+
const mockSubscriber = createMockRedisClient();
|
|
76
|
+
|
|
77
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
78
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
79
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await Effect.runPromise(
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const handler = vi.fn();
|
|
85
|
+
yield* broadcaster.subscribe("events", handler);
|
|
86
|
+
|
|
87
|
+
// Simulate Redis message
|
|
88
|
+
mockSubscriber.triggerMessage("events", "incoming-message");
|
|
89
|
+
|
|
90
|
+
expect(handler).toHaveBeenCalledWith("incoming-message");
|
|
91
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should call subscriberRedis.unsubscribe", async () => {
|
|
97
|
+
const mockRedis = createMockRedisClient();
|
|
98
|
+
const mockSubscriber = createMockRedisClient();
|
|
99
|
+
|
|
100
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
101
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
102
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await Effect.runPromise(
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
yield* broadcaster.unsubscribe!("channel-1");
|
|
108
|
+
|
|
109
|
+
expect(mockSubscriber.unsubscribe).toHaveBeenCalledWith("channel-1");
|
|
110
|
+
expect(mockSubscriber.unsubscribe).toHaveBeenCalledTimes(1);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("Error Handling", () => {
|
|
117
|
+
it("should return UploadistaError when publish fails", async () => {
|
|
118
|
+
const mockRedis = createMockRedisClient();
|
|
119
|
+
const mockSubscriber = createMockRedisClient();
|
|
120
|
+
|
|
121
|
+
mockRedis.publish.mockRejectedValueOnce(new Error("Connection failed"));
|
|
122
|
+
|
|
123
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
124
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
125
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await Effect.runPromise(
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const result = yield* Effect.either(
|
|
131
|
+
broadcaster.publish("channel", "message"),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(result._tag).toBe("Left");
|
|
135
|
+
if (result._tag === "Left") {
|
|
136
|
+
expect(result.left.code).toBe("UNKNOWN_ERROR");
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should return UploadistaError when subscribe fails", async () => {
|
|
143
|
+
const mockRedis = createMockRedisClient();
|
|
144
|
+
const mockSubscriber = createMockRedisClient();
|
|
145
|
+
|
|
146
|
+
mockSubscriber.subscribe.mockRejectedValueOnce(
|
|
147
|
+
new Error("Subscribe failed"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
151
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
152
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await Effect.runPromise(
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const result = yield* Effect.either(
|
|
158
|
+
broadcaster.subscribe("channel", vi.fn()),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result._tag).toBe("Left");
|
|
162
|
+
if (result._tag === "Left") {
|
|
163
|
+
expect(result.left.code).toBe("UNKNOWN_ERROR");
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should return UploadistaError when unsubscribe fails", async () => {
|
|
170
|
+
const mockRedis = createMockRedisClient();
|
|
171
|
+
const mockSubscriber = createMockRedisClient();
|
|
172
|
+
|
|
173
|
+
mockSubscriber.unsubscribe.mockRejectedValueOnce(
|
|
174
|
+
new Error("Unsubscribe failed"),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
178
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
179
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await Effect.runPromise(
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const result = yield* Effect.either(broadcaster.unsubscribe!("channel"));
|
|
185
|
+
|
|
186
|
+
expect(result._tag).toBe("Left");
|
|
187
|
+
if (result._tag === "Left") {
|
|
188
|
+
expect(result.left.code).toBe("UNKNOWN_ERROR");
|
|
189
|
+
}
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should register error handler for redis client", async () => {
|
|
195
|
+
const mockRedis = createMockRedisClient();
|
|
196
|
+
const mockSubscriber = createMockRedisClient();
|
|
197
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
198
|
+
|
|
199
|
+
createRedisEventBroadcaster({
|
|
200
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
201
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(mockRedis.on).toHaveBeenCalledWith("error", expect.any(Function));
|
|
205
|
+
|
|
206
|
+
// Trigger error
|
|
207
|
+
mockRedis.triggerError(new Error("Redis error"));
|
|
208
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
209
|
+
"[Redis] Error:",
|
|
210
|
+
expect.any(Error),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
consoleSpy.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should register error handler for subscriber client", async () => {
|
|
217
|
+
const mockRedis = createMockRedisClient();
|
|
218
|
+
const mockSubscriber = createMockRedisClient();
|
|
219
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
220
|
+
|
|
221
|
+
createRedisEventBroadcaster({
|
|
222
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
223
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(mockSubscriber.on).toHaveBeenCalledWith(
|
|
227
|
+
"error",
|
|
228
|
+
expect.any(Function),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Trigger error
|
|
232
|
+
mockSubscriber.triggerError(new Error("Subscriber error"));
|
|
233
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
234
|
+
"[Redis] Subscriber Error:",
|
|
235
|
+
expect.any(Error),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
consoleSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("Redis-Specific Behavior", () => {
|
|
243
|
+
it("should use separate redis and subscriberRedis clients", async () => {
|
|
244
|
+
const mockRedis = createMockRedisClient();
|
|
245
|
+
const mockSubscriber = createMockRedisClient();
|
|
246
|
+
|
|
247
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
248
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
249
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await Effect.runPromise(
|
|
253
|
+
Effect.gen(function* () {
|
|
254
|
+
yield* broadcaster.publish("channel", "message");
|
|
255
|
+
yield* broadcaster.subscribe("channel", vi.fn());
|
|
256
|
+
|
|
257
|
+
// Publish uses redis client
|
|
258
|
+
expect(mockRedis.publish).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(mockSubscriber.publish).not.toHaveBeenCalled();
|
|
260
|
+
|
|
261
|
+
// Subscribe uses subscriberRedis client
|
|
262
|
+
expect(mockSubscriber.subscribe).toHaveBeenCalledTimes(1);
|
|
263
|
+
expect(mockRedis.subscribe).not.toHaveBeenCalled();
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should support multiple subscribe calls with different handlers", async () => {
|
|
269
|
+
const mockRedis = createMockRedisClient();
|
|
270
|
+
const mockSubscriber = createMockRedisClient();
|
|
271
|
+
|
|
272
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
273
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
274
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await Effect.runPromise(
|
|
278
|
+
Effect.gen(function* () {
|
|
279
|
+
const handler1 = vi.fn();
|
|
280
|
+
const handler2 = vi.fn();
|
|
281
|
+
|
|
282
|
+
yield* broadcaster.subscribe("channel-1", handler1);
|
|
283
|
+
yield* broadcaster.subscribe("channel-2", handler2);
|
|
284
|
+
|
|
285
|
+
expect(mockSubscriber.subscribe).toHaveBeenCalledTimes(2);
|
|
286
|
+
expect(mockSubscriber.subscribe).toHaveBeenNthCalledWith(
|
|
287
|
+
1,
|
|
288
|
+
"channel-1",
|
|
289
|
+
expect.any(Function),
|
|
290
|
+
);
|
|
291
|
+
expect(mockSubscriber.subscribe).toHaveBeenNthCalledWith(
|
|
292
|
+
2,
|
|
293
|
+
"channel-2",
|
|
294
|
+
expect.any(Function),
|
|
295
|
+
);
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should pass correct channel and message to callback", async () => {
|
|
301
|
+
const mockRedis = createMockRedisClient();
|
|
302
|
+
const mockSubscriber = createMockRedisClient();
|
|
303
|
+
|
|
304
|
+
const broadcaster = createRedisEventBroadcaster({
|
|
305
|
+
redis: mockRedisClientAsType(mockRedis),
|
|
306
|
+
subscriberRedis: mockRedisClientAsType(mockSubscriber),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await Effect.runPromise(
|
|
310
|
+
Effect.gen(function* () {
|
|
311
|
+
const handler = vi.fn();
|
|
312
|
+
yield* broadcaster.subscribe("test-channel", handler);
|
|
313
|
+
|
|
314
|
+
// Simulate Redis delivering message with channel info
|
|
315
|
+
mockSubscriber.triggerMessage("test-channel", "test-payload");
|
|
316
|
+
|
|
317
|
+
expect(handler).toHaveBeenCalledWith("test-payload");
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RedisClientType } from "@redis/client";
|
|
2
|
+
import { vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
type MessageCallback = (message: string, channel: string) => void;
|
|
5
|
+
|
|
6
|
+
export interface MockRedisClient {
|
|
7
|
+
publish: ReturnType<typeof vi.fn>;
|
|
8
|
+
subscribe: ReturnType<typeof vi.fn>;
|
|
9
|
+
unsubscribe: ReturnType<typeof vi.fn>;
|
|
10
|
+
on: ReturnType<typeof vi.fn>;
|
|
11
|
+
triggerMessage: (channel: string, message: string) => void;
|
|
12
|
+
triggerError: (error: Error) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createMockRedisClient(): MockRedisClient {
|
|
16
|
+
const subscribers = new Map<string, Set<MessageCallback>>();
|
|
17
|
+
let errorHandler: ((error: Error) => void) | null = null;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
publish: vi.fn().mockResolvedValue(1),
|
|
21
|
+
|
|
22
|
+
subscribe: vi.fn().mockImplementation(
|
|
23
|
+
async (channel: string, callback: MessageCallback) => {
|
|
24
|
+
if (!subscribers.has(channel)) {
|
|
25
|
+
subscribers.set(channel, new Set());
|
|
26
|
+
}
|
|
27
|
+
subscribers.get(channel)?.add(callback);
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
|
|
31
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
|
|
33
|
+
on: vi.fn().mockImplementation((event: string, handler: (error: Error) => void) => {
|
|
34
|
+
if (event === "error") {
|
|
35
|
+
errorHandler = handler;
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
triggerMessage: (channel: string, message: string) => {
|
|
40
|
+
const channelSubs = subscribers.get(channel);
|
|
41
|
+
if (channelSubs) {
|
|
42
|
+
for (const callback of channelSubs) {
|
|
43
|
+
callback(message, channel);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
triggerError: (error: Error) => {
|
|
49
|
+
if (errorHandler) {
|
|
50
|
+
errorHandler(error);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mockRedisClientAsType(mock: MockRedisClient): RedisClientType {
|
|
57
|
+
return mock as unknown as RedisClientType;
|
|
58
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared vitest configuration template for uploadista-sdk packages
|
|
5
|
+
*
|
|
6
|
+
* This template should be used by all SDK packages to ensure consistent
|
|
7
|
+
* testing configuration across the monorepo.
|
|
8
|
+
*
|
|
9
|
+
* Key features:
|
|
10
|
+
* - Tests in dedicated `tests/` directories (not colocated with src)
|
|
11
|
+
* - Node environment for server-side code
|
|
12
|
+
* - V8 coverage provider
|
|
13
|
+
* - Global test functions available
|
|
14
|
+
* - Effect testing support via @effect/vitest
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* Copy this file to your package root as `vitest.config.ts` and customize
|
|
18
|
+
* if needed (though most packages should use this as-is).
|
|
19
|
+
*/
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
test: {
|
|
22
|
+
globals: true,
|
|
23
|
+
environment: "node",
|
|
24
|
+
include: ["tests/**/*.test.ts"],
|
|
25
|
+
exclude: ["node_modules", "dist"],
|
|
26
|
+
coverage: {
|
|
27
|
+
provider: "v8",
|
|
28
|
+
reporter: ["text", "json", "html"],
|
|
29
|
+
exclude: [
|
|
30
|
+
"node_modules/",
|
|
31
|
+
"dist/",
|
|
32
|
+
"**/*.d.ts",
|
|
33
|
+
"**/*.test.ts",
|
|
34
|
+
"**/*.spec.ts",
|
|
35
|
+
"tests/",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|