@openclaw/twitch 2026.2.21

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/src/token.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Twitch access token resolution with environment variable support.
3
+ *
4
+ * Supports reading Twitch OAuth access tokens from config or environment variable.
5
+ * The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
6
+ *
7
+ * Token resolution priority:
8
+ * 1. Account access token from merged config (accounts.{id} or base-level for default)
9
+ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
10
+ */
11
+
12
+ import type { OpenClawConfig } from "../../../src/config/config.js";
13
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
14
+
15
+ export type TwitchTokenSource = "env" | "config" | "none";
16
+
17
+ export type TwitchTokenResolution = {
18
+ token: string;
19
+ source: TwitchTokenSource;
20
+ };
21
+
22
+ /**
23
+ * Normalize a Twitch OAuth token - ensure it has the oauth: prefix
24
+ */
25
+ function normalizeTwitchToken(raw?: string | null): string | undefined {
26
+ if (!raw) {
27
+ return undefined;
28
+ }
29
+ const trimmed = raw.trim();
30
+ if (!trimmed) {
31
+ return undefined;
32
+ }
33
+ // Twitch tokens should have oauth: prefix
34
+ return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
35
+ }
36
+
37
+ /**
38
+ * Resolve Twitch access token from config or environment variable.
39
+ *
40
+ * Priority:
41
+ * 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
42
+ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
43
+ *
44
+ * The getAccountConfig function handles merging base-level config with accounts.default,
45
+ * so this logic works for both simplified and multi-account patterns.
46
+ *
47
+ * @param cfg - OpenClaw config
48
+ * @param opts - Options including accountId and optional envToken override
49
+ * @returns Token resolution with source
50
+ */
51
+ export function resolveTwitchToken(
52
+ cfg?: OpenClawConfig,
53
+ opts: { accountId?: string | null; envToken?: string | null } = {},
54
+ ): TwitchTokenResolution {
55
+ const accountId = normalizeAccountId(opts.accountId);
56
+
57
+ // Get merged account config (handles both simplified and multi-account patterns)
58
+ const twitchCfg = cfg?.channels?.twitch;
59
+ const accountCfg =
60
+ accountId === DEFAULT_ACCOUNT_ID
61
+ ? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
62
+ : (twitchCfg?.accounts?.[accountId] as Record<string, unknown> | undefined);
63
+
64
+ // For default account, also check base-level config
65
+ let token: string | undefined;
66
+ if (accountId === DEFAULT_ACCOUNT_ID) {
67
+ // Base-level config takes precedence
68
+ token = normalizeTwitchToken(
69
+ (typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
70
+ (accountCfg?.accessToken as string | undefined),
71
+ );
72
+ } else {
73
+ // Non-default accounts only use accounts object
74
+ token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
75
+ }
76
+
77
+ if (token) {
78
+ return { token, source: "config" };
79
+ }
80
+
81
+ // Environment variable (default account only)
82
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
83
+ const envToken = allowEnv
84
+ ? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN)
85
+ : undefined;
86
+ if (envToken) {
87
+ return { token: envToken, source: "env" };
88
+ }
89
+
90
+ return { token: "", source: "none" };
91
+ }
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Tests for TwitchClientManager class
3
+ *
4
+ * Tests cover:
5
+ * - Client connection and reconnection
6
+ * - Message handling (chat)
7
+ * - Message sending with rate limiting
8
+ * - Disconnection scenarios
9
+ * - Error handling and edge cases
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { TwitchClientManager } from "./twitch-client.js";
14
+ import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
15
+
16
+ // Mock @twurple dependencies
17
+ const mockConnect = vi.fn().mockResolvedValue(undefined);
18
+ const mockJoin = vi.fn().mockResolvedValue(undefined);
19
+ const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
20
+ const mockQuit = vi.fn();
21
+ const mockUnbind = vi.fn();
22
+
23
+ // Event handler storage for testing
24
+ // oxlint-disable-next-line typescript/no-explicit-any
25
+ const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
26
+ [];
27
+
28
+ // Mock functions that track handlers and return unbind objects
29
+ // oxlint-disable-next-line typescript/no-explicit-any
30
+ const mockOnMessage = vi.fn((handler: any) => {
31
+ messageHandlers.push(handler);
32
+ return { unbind: mockUnbind };
33
+ });
34
+
35
+ const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
36
+ const mockOnRefresh = vi.fn();
37
+ const mockOnRefreshFailure = vi.fn();
38
+
39
+ vi.mock("@twurple/chat", () => ({
40
+ ChatClient: class {
41
+ onMessage = mockOnMessage;
42
+ connect = mockConnect;
43
+ join = mockJoin;
44
+ say = mockSay;
45
+ quit = mockQuit;
46
+ },
47
+ LogLevel: {
48
+ CRITICAL: "CRITICAL",
49
+ ERROR: "ERROR",
50
+ WARNING: "WARNING",
51
+ INFO: "INFO",
52
+ DEBUG: "DEBUG",
53
+ TRACE: "TRACE",
54
+ },
55
+ }));
56
+
57
+ const mockAuthProvider = {
58
+ constructor: vi.fn(),
59
+ };
60
+
61
+ vi.mock("@twurple/auth", () => ({
62
+ StaticAuthProvider: class {
63
+ constructor(...args: unknown[]) {
64
+ mockAuthProvider.constructor(...args);
65
+ }
66
+ },
67
+ RefreshingAuthProvider: class {
68
+ addUserForToken = mockAddUserForToken;
69
+ onRefresh = mockOnRefresh;
70
+ onRefreshFailure = mockOnRefreshFailure;
71
+ },
72
+ }));
73
+
74
+ // Mock token resolution - must be after @twurple/auth mock
75
+ vi.mock("./token.js", () => ({
76
+ resolveTwitchToken: vi.fn(() => ({
77
+ token: "oauth:mock-token-from-tests",
78
+ source: "config" as const,
79
+ })),
80
+ DEFAULT_ACCOUNT_ID: "default",
81
+ }));
82
+
83
+ describe("TwitchClientManager", () => {
84
+ let manager: TwitchClientManager;
85
+ let mockLogger: ChannelLogSink;
86
+
87
+ const testAccount: TwitchAccountConfig = {
88
+ username: "testbot",
89
+ accessToken: "test123456",
90
+ clientId: "test-client-id",
91
+ channel: "testchannel",
92
+ enabled: true,
93
+ };
94
+
95
+ const testAccount2: TwitchAccountConfig = {
96
+ username: "testbot2",
97
+ accessToken: "test789",
98
+ clientId: "test-client-id-2",
99
+ channel: "testchannel2",
100
+ enabled: true,
101
+ };
102
+
103
+ beforeEach(async () => {
104
+ // Clear all mocks first
105
+ vi.clearAllMocks();
106
+
107
+ // Clear handler arrays
108
+ messageHandlers.length = 0;
109
+
110
+ // Re-set up the default token mock implementation after clearing
111
+ const { resolveTwitchToken } = await import("./token.js");
112
+ vi.mocked(resolveTwitchToken).mockReturnValue({
113
+ token: "oauth:mock-token-from-tests",
114
+ source: "config" as const,
115
+ });
116
+
117
+ // Create mock logger
118
+ mockLogger = {
119
+ info: vi.fn(),
120
+ warn: vi.fn(),
121
+ error: vi.fn(),
122
+ debug: vi.fn(),
123
+ };
124
+
125
+ // Create manager instance
126
+ manager = new TwitchClientManager(mockLogger);
127
+ });
128
+
129
+ afterEach(() => {
130
+ // Clean up manager to avoid side effects
131
+ manager._clearForTest();
132
+ });
133
+
134
+ describe("getClient", () => {
135
+ it("should create a new client connection", async () => {
136
+ const _client = await manager.getClient(testAccount);
137
+
138
+ // New implementation: connect is called, channels are passed to constructor
139
+ expect(mockConnect).toHaveBeenCalledTimes(1);
140
+ expect(mockLogger.info).toHaveBeenCalledWith(
141
+ expect.stringContaining("Connected to Twitch as testbot"),
142
+ );
143
+ });
144
+
145
+ it("should use account username as default channel when channel not specified", async () => {
146
+ const accountWithoutChannel: TwitchAccountConfig = {
147
+ ...testAccount,
148
+ channel: "",
149
+ } as unknown as TwitchAccountConfig;
150
+
151
+ await manager.getClient(accountWithoutChannel);
152
+
153
+ // New implementation: channel (testbot) is passed to constructor, not via join()
154
+ expect(mockConnect).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ it("should reuse existing client for same account", async () => {
158
+ const client1 = await manager.getClient(testAccount);
159
+ const client2 = await manager.getClient(testAccount);
160
+
161
+ expect(client1).toBe(client2);
162
+ expect(mockConnect).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it("should create separate clients for different accounts", async () => {
166
+ await manager.getClient(testAccount);
167
+ await manager.getClient(testAccount2);
168
+
169
+ expect(mockConnect).toHaveBeenCalledTimes(2);
170
+ });
171
+
172
+ it("should normalize token by removing oauth: prefix", async () => {
173
+ const accountWithPrefix: TwitchAccountConfig = {
174
+ ...testAccount,
175
+ accessToken: "oauth:actualtoken123",
176
+ };
177
+
178
+ // Override the mock to return a specific token for this test
179
+ const { resolveTwitchToken } = await import("./token.js");
180
+ vi.mocked(resolveTwitchToken).mockReturnValue({
181
+ token: "oauth:actualtoken123",
182
+ source: "config" as const,
183
+ });
184
+
185
+ await manager.getClient(accountWithPrefix);
186
+
187
+ expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
188
+ });
189
+
190
+ it("should use token directly when no oauth: prefix", async () => {
191
+ // Override the mock to return a token without oauth: prefix
192
+ const { resolveTwitchToken } = await import("./token.js");
193
+ vi.mocked(resolveTwitchToken).mockReturnValue({
194
+ token: "oauth:mock-token-from-tests",
195
+ source: "config" as const,
196
+ });
197
+
198
+ await manager.getClient(testAccount);
199
+
200
+ // Implementation strips oauth: prefix from all tokens
201
+ expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
202
+ "test-client-id",
203
+ "mock-token-from-tests",
204
+ );
205
+ });
206
+
207
+ it("should throw error when clientId is missing", async () => {
208
+ const accountWithoutClientId: TwitchAccountConfig = {
209
+ ...testAccount,
210
+ clientId: "" as unknown as string,
211
+ } as unknown as TwitchAccountConfig;
212
+
213
+ await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
214
+ "Missing Twitch client ID",
215
+ );
216
+
217
+ expect(mockLogger.error).toHaveBeenCalledWith(
218
+ expect.stringContaining("Missing Twitch client ID"),
219
+ );
220
+ });
221
+
222
+ it("should throw error when token is missing", async () => {
223
+ // Override the mock to return empty token
224
+ const { resolveTwitchToken } = await import("./token.js");
225
+ vi.mocked(resolveTwitchToken).mockReturnValue({
226
+ token: "",
227
+ source: "none" as const,
228
+ });
229
+
230
+ await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
231
+ });
232
+
233
+ it("should set up message handlers on client connection", async () => {
234
+ await manager.getClient(testAccount);
235
+
236
+ expect(mockOnMessage).toHaveBeenCalled();
237
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
238
+ });
239
+
240
+ it("should create separate clients for same account with different channels", async () => {
241
+ const account1: TwitchAccountConfig = {
242
+ ...testAccount,
243
+ channel: "channel1",
244
+ };
245
+ const account2: TwitchAccountConfig = {
246
+ ...testAccount,
247
+ channel: "channel2",
248
+ };
249
+
250
+ await manager.getClient(account1);
251
+ await manager.getClient(account2);
252
+
253
+ expect(mockConnect).toHaveBeenCalledTimes(2);
254
+ });
255
+ });
256
+
257
+ describe("onMessage", () => {
258
+ it("should register message handler for account", () => {
259
+ const handler = vi.fn();
260
+ manager.onMessage(testAccount, handler);
261
+
262
+ expect(handler).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it("should replace existing handler for same account", () => {
266
+ const handler1 = vi.fn();
267
+ const handler2 = vi.fn();
268
+
269
+ manager.onMessage(testAccount, handler1);
270
+ manager.onMessage(testAccount, handler2);
271
+
272
+ // Check the stored handler is handler2
273
+ const key = manager.getAccountKey(testAccount);
274
+ // oxlint-disable-next-line typescript/no-explicit-any
275
+ expect((manager as any).messageHandlers.get(key)).toBe(handler2);
276
+ });
277
+ });
278
+
279
+ describe("disconnect", () => {
280
+ it("should disconnect a connected client", async () => {
281
+ await manager.getClient(testAccount);
282
+ await manager.disconnect(testAccount);
283
+
284
+ expect(mockQuit).toHaveBeenCalledTimes(1);
285
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
286
+ });
287
+
288
+ it("should clear client and message handler", async () => {
289
+ const handler = vi.fn();
290
+ await manager.getClient(testAccount);
291
+ manager.onMessage(testAccount, handler);
292
+
293
+ await manager.disconnect(testAccount);
294
+
295
+ const key = manager.getAccountKey(testAccount);
296
+ // oxlint-disable-next-line typescript/no-explicit-any
297
+ expect((manager as any).clients.has(key)).toBe(false);
298
+ // oxlint-disable-next-line typescript/no-explicit-any
299
+ expect((manager as any).messageHandlers.has(key)).toBe(false);
300
+ });
301
+
302
+ it("should handle disconnecting non-existent client gracefully", async () => {
303
+ // disconnect doesn't throw, just does nothing
304
+ await manager.disconnect(testAccount);
305
+ expect(mockQuit).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it("should only disconnect specified account when multiple accounts exist", async () => {
309
+ await manager.getClient(testAccount);
310
+ await manager.getClient(testAccount2);
311
+
312
+ await manager.disconnect(testAccount);
313
+
314
+ expect(mockQuit).toHaveBeenCalledTimes(1);
315
+
316
+ const key2 = manager.getAccountKey(testAccount2);
317
+ // oxlint-disable-next-line typescript/no-explicit-any
318
+ expect((manager as any).clients.has(key2)).toBe(true);
319
+ });
320
+ });
321
+
322
+ describe("disconnectAll", () => {
323
+ it("should disconnect all connected clients", async () => {
324
+ await manager.getClient(testAccount);
325
+ await manager.getClient(testAccount2);
326
+
327
+ await manager.disconnectAll();
328
+
329
+ expect(mockQuit).toHaveBeenCalledTimes(2);
330
+ // oxlint-disable-next-line typescript/no-explicit-any
331
+ expect((manager as any).clients.size).toBe(0);
332
+ // oxlint-disable-next-line typescript/no-explicit-any
333
+ expect((manager as any).messageHandlers.size).toBe(0);
334
+ });
335
+
336
+ it("should handle empty client list gracefully", async () => {
337
+ // disconnectAll doesn't throw, just does nothing
338
+ await manager.disconnectAll();
339
+ expect(mockQuit).not.toHaveBeenCalled();
340
+ });
341
+ });
342
+
343
+ describe("sendMessage", () => {
344
+ beforeEach(async () => {
345
+ await manager.getClient(testAccount);
346
+ });
347
+
348
+ it("should send message successfully", async () => {
349
+ const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
350
+
351
+ expect(result.ok).toBe(true);
352
+ expect(result.messageId).toBeDefined();
353
+ expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
354
+ });
355
+
356
+ it("should generate unique message ID for each message", async () => {
357
+ const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
358
+ const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
359
+
360
+ expect(result1.messageId).not.toBe(result2.messageId);
361
+ });
362
+
363
+ it("should handle sending to account's default channel", async () => {
364
+ const result = await manager.sendMessage(
365
+ testAccount,
366
+ testAccount.channel || testAccount.username,
367
+ "Test message",
368
+ );
369
+
370
+ // Should use the account's channel or username
371
+ expect(result.ok).toBe(true);
372
+ expect(mockSay).toHaveBeenCalled();
373
+ });
374
+
375
+ it("should return error on send failure", async () => {
376
+ mockSay.mockRejectedValueOnce(new Error("Rate limited"));
377
+
378
+ const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
379
+
380
+ expect(result.ok).toBe(false);
381
+ expect(result.error).toBe("Rate limited");
382
+ expect(mockLogger.error).toHaveBeenCalledWith(
383
+ expect.stringContaining("Failed to send message"),
384
+ );
385
+ });
386
+
387
+ it("should handle unknown error types", async () => {
388
+ mockSay.mockRejectedValueOnce("String error");
389
+
390
+ const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
391
+
392
+ expect(result.ok).toBe(false);
393
+ expect(result.error).toBe("String error");
394
+ });
395
+
396
+ it("should create client if not already connected", async () => {
397
+ // Clear the existing client
398
+ // oxlint-disable-next-line typescript/no-explicit-any
399
+ (manager as any).clients.clear();
400
+
401
+ // Reset connect call count for this specific test
402
+ const connectCallCountBefore = mockConnect.mock.calls.length;
403
+
404
+ const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
405
+
406
+ expect(result.ok).toBe(true);
407
+ expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
408
+ });
409
+ });
410
+
411
+ describe("message handling integration", () => {
412
+ let capturedMessage: TwitchChatMessage | null = null;
413
+
414
+ beforeEach(() => {
415
+ capturedMessage = null;
416
+
417
+ // Set up message handler before connecting
418
+ manager.onMessage(testAccount, (message) => {
419
+ capturedMessage = message;
420
+ });
421
+ });
422
+
423
+ it("should handle incoming chat messages", async () => {
424
+ await manager.getClient(testAccount);
425
+
426
+ // Get the onMessage callback
427
+ const onMessageCallback = messageHandlers[0];
428
+ if (!onMessageCallback) {
429
+ throw new Error("onMessageCallback not found");
430
+ }
431
+
432
+ // Simulate Twitch message
433
+ onMessageCallback("#testchannel", "testuser", "Hello bot!", {
434
+ userInfo: {
435
+ userName: "testuser",
436
+ displayName: "TestUser",
437
+ userId: "12345",
438
+ isMod: false,
439
+ isBroadcaster: false,
440
+ isVip: false,
441
+ isSubscriber: false,
442
+ },
443
+ id: "msg123",
444
+ });
445
+
446
+ expect(capturedMessage).not.toBeNull();
447
+ expect(capturedMessage?.username).toBe("testuser");
448
+ expect(capturedMessage?.displayName).toBe("TestUser");
449
+ expect(capturedMessage?.userId).toBe("12345");
450
+ expect(capturedMessage?.message).toBe("Hello bot!");
451
+ expect(capturedMessage?.channel).toBe("testchannel");
452
+ expect(capturedMessage?.chatType).toBe("group");
453
+ });
454
+
455
+ it("should normalize channel names without # prefix", async () => {
456
+ await manager.getClient(testAccount);
457
+
458
+ const onMessageCallback = messageHandlers[0];
459
+
460
+ onMessageCallback("testchannel", "testuser", "Test", {
461
+ userInfo: {
462
+ userName: "testuser",
463
+ displayName: "TestUser",
464
+ userId: "123",
465
+ isMod: false,
466
+ isBroadcaster: false,
467
+ isVip: false,
468
+ isSubscriber: false,
469
+ },
470
+ id: "msg1",
471
+ });
472
+
473
+ expect(capturedMessage?.channel).toBe("testchannel");
474
+ });
475
+
476
+ it("should include user role flags in message", async () => {
477
+ await manager.getClient(testAccount);
478
+
479
+ const onMessageCallback = messageHandlers[0];
480
+
481
+ onMessageCallback("#testchannel", "moduser", "Test", {
482
+ userInfo: {
483
+ userName: "moduser",
484
+ displayName: "ModUser",
485
+ userId: "456",
486
+ isMod: true,
487
+ isBroadcaster: false,
488
+ isVip: true,
489
+ isSubscriber: true,
490
+ },
491
+ id: "msg2",
492
+ });
493
+
494
+ expect(capturedMessage?.isMod).toBe(true);
495
+ expect(capturedMessage?.isVip).toBe(true);
496
+ expect(capturedMessage?.isSub).toBe(true);
497
+ expect(capturedMessage?.isOwner).toBe(false);
498
+ });
499
+
500
+ it("should handle broadcaster messages", async () => {
501
+ await manager.getClient(testAccount);
502
+
503
+ const onMessageCallback = messageHandlers[0];
504
+
505
+ onMessageCallback("#testchannel", "broadcaster", "Test", {
506
+ userInfo: {
507
+ userName: "broadcaster",
508
+ displayName: "Broadcaster",
509
+ userId: "789",
510
+ isMod: false,
511
+ isBroadcaster: true,
512
+ isVip: false,
513
+ isSubscriber: false,
514
+ },
515
+ id: "msg3",
516
+ });
517
+
518
+ expect(capturedMessage?.isOwner).toBe(true);
519
+ });
520
+ });
521
+
522
+ describe("edge cases", () => {
523
+ it("should handle multiple message handlers for different accounts", async () => {
524
+ const messages1: TwitchChatMessage[] = [];
525
+ const messages2: TwitchChatMessage[] = [];
526
+
527
+ manager.onMessage(testAccount, (msg) => messages1.push(msg));
528
+ manager.onMessage(testAccount2, (msg) => messages2.push(msg));
529
+
530
+ await manager.getClient(testAccount);
531
+ await manager.getClient(testAccount2);
532
+
533
+ // Simulate message for first account
534
+ const onMessage1 = messageHandlers[0];
535
+ if (!onMessage1) {
536
+ throw new Error("onMessage1 not found");
537
+ }
538
+ onMessage1("#testchannel", "user1", "msg1", {
539
+ userInfo: {
540
+ userName: "user1",
541
+ displayName: "User1",
542
+ userId: "1",
543
+ isMod: false,
544
+ isBroadcaster: false,
545
+ isVip: false,
546
+ isSubscriber: false,
547
+ },
548
+ id: "1",
549
+ });
550
+
551
+ // Simulate message for second account
552
+ const onMessage2 = messageHandlers[1];
553
+ if (!onMessage2) {
554
+ throw new Error("onMessage2 not found");
555
+ }
556
+ onMessage2("#testchannel2", "user2", "msg2", {
557
+ userInfo: {
558
+ userName: "user2",
559
+ displayName: "User2",
560
+ userId: "2",
561
+ isMod: false,
562
+ isBroadcaster: false,
563
+ isVip: false,
564
+ isSubscriber: false,
565
+ },
566
+ id: "2",
567
+ });
568
+
569
+ expect(messages1).toHaveLength(1);
570
+ expect(messages2).toHaveLength(1);
571
+ expect(messages1[0]?.message).toBe("msg1");
572
+ expect(messages2[0]?.message).toBe("msg2");
573
+ });
574
+
575
+ it("should handle rapid client creation requests", async () => {
576
+ const promises = [
577
+ manager.getClient(testAccount),
578
+ manager.getClient(testAccount),
579
+ manager.getClient(testAccount),
580
+ ];
581
+
582
+ await Promise.all(promises);
583
+
584
+ // Note: The implementation doesn't handle concurrent getClient calls,
585
+ // so multiple connections may be created. This is expected behavior.
586
+ expect(mockConnect).toHaveBeenCalled();
587
+ });
588
+ });
589
+ });