* wip * copy polugin files * wip type changes * refactor: improve Twitch plugin code quality and fix all tests - Extract client manager registry for centralized lifecycle management - Refactor to use early returns and reduce mutations - Fix status check logic for clientId detection - Add comprehensive test coverage for new modules - Remove tests for unimplemented features (index.test.ts, resolver.test.ts) - Fix mock setup issues in test suite (149 tests now passing) - Improve error handling with errorResponse helper in actions.ts - Normalize token handling to eliminate duplication Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * use accountId * delete md file * delte tsconfig * adjust log level * fix probe logic * format * fix monitor * code review fixes * format * no mutation * less mutation * chain debug log * await authProvider setup * use uuid * use spread * fix tests * update docs and remove bot channel fallback * more readme fixes * remove comments + fromat * fix tests * adjust access control logic * format * install * simplify config object * remove duplicate log tags + log received messages * update docs * update tests * format * strip markdown in monitor * remove strip markdown config, enabled by default * default requireMention to true * fix store path arg * fix multi account id + add unit test * fix multi account id + add unit test * make channel required and update docs * remove whisper functionality * remove duplicate connect log * update docs with convert twitch link * make twitch message processing non blocking * schema consistent casing * remove noisy ignore log * use coreLogger --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
575 lines
17 KiB
TypeScript
575 lines
17 KiB
TypeScript
/**
|
|
* Tests for TwitchClientManager class
|
|
*
|
|
* Tests cover:
|
|
* - Client connection and reconnection
|
|
* - Message handling (chat)
|
|
* - Message sending with rate limiting
|
|
* - Disconnection scenarios
|
|
* - Error handling and edge cases
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TwitchClientManager } from "./twitch-client.js";
|
|
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
|
|
// Mock @twurple dependencies
|
|
const mockConnect = vi.fn().mockResolvedValue(undefined);
|
|
const mockJoin = vi.fn().mockResolvedValue(undefined);
|
|
const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
|
|
const mockQuit = vi.fn();
|
|
const mockUnbind = vi.fn();
|
|
|
|
// Event handler storage for testing
|
|
const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
|
|
[];
|
|
|
|
// Mock functions that track handlers and return unbind objects
|
|
const mockOnMessage = vi.fn((handler: any) => {
|
|
messageHandlers.push(handler);
|
|
return { unbind: mockUnbind };
|
|
});
|
|
|
|
const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
|
|
const mockOnRefresh = vi.fn();
|
|
const mockOnRefreshFailure = vi.fn();
|
|
|
|
vi.mock("@twurple/chat", () => ({
|
|
ChatClient: class {
|
|
onMessage = mockOnMessage;
|
|
connect = mockConnect;
|
|
join = mockJoin;
|
|
say = mockSay;
|
|
quit = mockQuit;
|
|
},
|
|
LogLevel: {
|
|
CRITICAL: "CRITICAL",
|
|
ERROR: "ERROR",
|
|
WARNING: "WARNING",
|
|
INFO: "INFO",
|
|
DEBUG: "DEBUG",
|
|
TRACE: "TRACE",
|
|
},
|
|
}));
|
|
|
|
const mockAuthProvider = {
|
|
constructor: vi.fn(),
|
|
};
|
|
|
|
vi.mock("@twurple/auth", () => ({
|
|
StaticAuthProvider: class {
|
|
constructor(...args: unknown[]) {
|
|
mockAuthProvider.constructor(...args);
|
|
}
|
|
},
|
|
RefreshingAuthProvider: class {
|
|
addUserForToken = mockAddUserForToken;
|
|
onRefresh = mockOnRefresh;
|
|
onRefreshFailure = mockOnRefreshFailure;
|
|
},
|
|
}));
|
|
|
|
// Mock token resolution - must be after @twurple/auth mock
|
|
vi.mock("./token.js", () => ({
|
|
resolveTwitchToken: vi.fn(() => ({
|
|
token: "oauth:mock-token-from-tests",
|
|
source: "config" as const,
|
|
})),
|
|
DEFAULT_ACCOUNT_ID: "default",
|
|
}));
|
|
|
|
describe("TwitchClientManager", () => {
|
|
let manager: TwitchClientManager;
|
|
let mockLogger: ChannelLogSink;
|
|
|
|
const testAccount: TwitchAccountConfig = {
|
|
username: "testbot",
|
|
token: "oauth:test123456",
|
|
clientId: "test-client-id",
|
|
channel: "testchannel",
|
|
enabled: true,
|
|
};
|
|
|
|
const testAccount2: TwitchAccountConfig = {
|
|
username: "testbot2",
|
|
token: "oauth:test789",
|
|
clientId: "test-client-id-2",
|
|
channel: "testchannel2",
|
|
enabled: true,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
// Clear all mocks first
|
|
vi.clearAllMocks();
|
|
|
|
// Clear handler arrays
|
|
messageHandlers.length = 0;
|
|
|
|
// Re-set up the default token mock implementation after clearing
|
|
const { resolveTwitchToken } = await import("./token.js");
|
|
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
token: "oauth:mock-token-from-tests",
|
|
source: "config" as const,
|
|
});
|
|
|
|
// Create mock logger
|
|
mockLogger = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
};
|
|
|
|
// Create manager instance
|
|
manager = new TwitchClientManager(mockLogger);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up manager to avoid side effects
|
|
manager._clearForTest();
|
|
});
|
|
|
|
describe("getClient", () => {
|
|
it("should create a new client connection", async () => {
|
|
const _client = await manager.getClient(testAccount);
|
|
|
|
// New implementation: connect is called, channels are passed to constructor
|
|
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining("Connected to Twitch as testbot"),
|
|
);
|
|
});
|
|
|
|
it("should use account username as default channel when channel not specified", async () => {
|
|
const accountWithoutChannel: TwitchAccountConfig = {
|
|
...testAccount,
|
|
channel: undefined,
|
|
};
|
|
|
|
await manager.getClient(accountWithoutChannel);
|
|
|
|
// New implementation: channel (testbot) is passed to constructor, not via join()
|
|
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should reuse existing client for same account", async () => {
|
|
const client1 = await manager.getClient(testAccount);
|
|
const client2 = await manager.getClient(testAccount);
|
|
|
|
expect(client1).toBe(client2);
|
|
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should create separate clients for different accounts", async () => {
|
|
await manager.getClient(testAccount);
|
|
await manager.getClient(testAccount2);
|
|
|
|
expect(mockConnect).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should normalize token by removing oauth: prefix", async () => {
|
|
const accountWithPrefix: TwitchAccountConfig = {
|
|
...testAccount,
|
|
token: "oauth:actualtoken123",
|
|
};
|
|
|
|
// Override the mock to return a specific token for this test
|
|
const { resolveTwitchToken } = await import("./token.js");
|
|
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
token: "oauth:actualtoken123",
|
|
source: "config" as const,
|
|
});
|
|
|
|
await manager.getClient(accountWithPrefix);
|
|
|
|
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
|
|
});
|
|
|
|
it("should use token directly when no oauth: prefix", async () => {
|
|
// Override the mock to return a token without oauth: prefix
|
|
const { resolveTwitchToken } = await import("./token.js");
|
|
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
token: "oauth:mock-token-from-tests",
|
|
source: "config" as const,
|
|
});
|
|
|
|
await manager.getClient(testAccount);
|
|
|
|
// Implementation strips oauth: prefix from all tokens
|
|
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
|
|
"test-client-id",
|
|
"mock-token-from-tests",
|
|
);
|
|
});
|
|
|
|
it("should throw error when clientId is missing", async () => {
|
|
const accountWithoutClientId: TwitchAccountConfig = {
|
|
...testAccount,
|
|
clientId: undefined,
|
|
};
|
|
|
|
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
|
|
"Missing Twitch client ID",
|
|
);
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Missing Twitch client ID"),
|
|
);
|
|
});
|
|
|
|
it("should throw error when token is missing", async () => {
|
|
// Override the mock to return empty token
|
|
const { resolveTwitchToken } = await import("./token.js");
|
|
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
token: "",
|
|
source: "none" as const,
|
|
});
|
|
|
|
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
|
|
});
|
|
|
|
it("should set up message handlers on client connection", async () => {
|
|
await manager.getClient(testAccount);
|
|
|
|
expect(mockOnMessage).toHaveBeenCalled();
|
|
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
|
|
});
|
|
|
|
it("should create separate clients for same account with different channels", async () => {
|
|
const account1: TwitchAccountConfig = {
|
|
...testAccount,
|
|
channel: "channel1",
|
|
};
|
|
const account2: TwitchAccountConfig = {
|
|
...testAccount,
|
|
channel: "channel2",
|
|
};
|
|
|
|
await manager.getClient(account1);
|
|
await manager.getClient(account2);
|
|
|
|
expect(mockConnect).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("onMessage", () => {
|
|
it("should register message handler for account", () => {
|
|
const handler = vi.fn();
|
|
manager.onMessage(testAccount, handler);
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should replace existing handler for same account", () => {
|
|
const handler1 = vi.fn();
|
|
const handler2 = vi.fn();
|
|
|
|
manager.onMessage(testAccount, handler1);
|
|
manager.onMessage(testAccount, handler2);
|
|
|
|
// Check the stored handler is handler2
|
|
const key = manager.getAccountKey(testAccount);
|
|
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
|
|
});
|
|
});
|
|
|
|
describe("disconnect", () => {
|
|
it("should disconnect a connected client", async () => {
|
|
await manager.getClient(testAccount);
|
|
await manager.disconnect(testAccount);
|
|
|
|
expect(mockQuit).toHaveBeenCalledTimes(1);
|
|
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
|
|
});
|
|
|
|
it("should clear client and message handler", async () => {
|
|
const handler = vi.fn();
|
|
await manager.getClient(testAccount);
|
|
manager.onMessage(testAccount, handler);
|
|
|
|
await manager.disconnect(testAccount);
|
|
|
|
const key = manager.getAccountKey(testAccount);
|
|
expect((manager as any).clients.has(key)).toBe(false);
|
|
expect((manager as any).messageHandlers.has(key)).toBe(false);
|
|
});
|
|
|
|
it("should handle disconnecting non-existent client gracefully", async () => {
|
|
// disconnect doesn't throw, just does nothing
|
|
await manager.disconnect(testAccount);
|
|
expect(mockQuit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should only disconnect specified account when multiple accounts exist", async () => {
|
|
await manager.getClient(testAccount);
|
|
await manager.getClient(testAccount2);
|
|
|
|
await manager.disconnect(testAccount);
|
|
|
|
expect(mockQuit).toHaveBeenCalledTimes(1);
|
|
|
|
const key2 = manager.getAccountKey(testAccount2);
|
|
expect((manager as any).clients.has(key2)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("disconnectAll", () => {
|
|
it("should disconnect all connected clients", async () => {
|
|
await manager.getClient(testAccount);
|
|
await manager.getClient(testAccount2);
|
|
|
|
await manager.disconnectAll();
|
|
|
|
expect(mockQuit).toHaveBeenCalledTimes(2);
|
|
expect((manager as any).clients.size).toBe(0);
|
|
expect((manager as any).messageHandlers.size).toBe(0);
|
|
});
|
|
|
|
it("should handle empty client list gracefully", async () => {
|
|
// disconnectAll doesn't throw, just does nothing
|
|
await manager.disconnectAll();
|
|
expect(mockQuit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("sendMessage", () => {
|
|
beforeEach(async () => {
|
|
await manager.getClient(testAccount);
|
|
});
|
|
|
|
it("should send message successfully", async () => {
|
|
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.messageId).toBeDefined();
|
|
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
|
|
});
|
|
|
|
it("should generate unique message ID for each message", async () => {
|
|
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
|
|
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
|
|
|
|
expect(result1.messageId).not.toBe(result2.messageId);
|
|
});
|
|
|
|
it("should handle sending to account's default channel", async () => {
|
|
const result = await manager.sendMessage(
|
|
testAccount,
|
|
testAccount.channel || testAccount.username,
|
|
"Test message",
|
|
);
|
|
|
|
// Should use the account's channel or username
|
|
expect(result.ok).toBe(true);
|
|
expect(mockSay).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return error on send failure", async () => {
|
|
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
|
|
|
|
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe("Rate limited");
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to send message"),
|
|
);
|
|
});
|
|
|
|
it("should handle unknown error types", async () => {
|
|
mockSay.mockRejectedValueOnce("String error");
|
|
|
|
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe("String error");
|
|
});
|
|
|
|
it("should create client if not already connected", async () => {
|
|
// Clear the existing client
|
|
(manager as any).clients.clear();
|
|
|
|
// Reset connect call count for this specific test
|
|
const connectCallCountBefore = mockConnect.mock.calls.length;
|
|
|
|
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
|
|
});
|
|
});
|
|
|
|
describe("message handling integration", () => {
|
|
let capturedMessage: TwitchChatMessage | null = null;
|
|
|
|
beforeEach(() => {
|
|
capturedMessage = null;
|
|
|
|
// Set up message handler before connecting
|
|
manager.onMessage(testAccount, (message) => {
|
|
capturedMessage = message;
|
|
});
|
|
});
|
|
|
|
it("should handle incoming chat messages", async () => {
|
|
await manager.getClient(testAccount);
|
|
|
|
// Get the onMessage callback
|
|
const onMessageCallback = messageHandlers[0];
|
|
if (!onMessageCallback) throw new Error("onMessageCallback not found");
|
|
|
|
// Simulate Twitch message
|
|
onMessageCallback("#testchannel", "testuser", "Hello bot!", {
|
|
userInfo: {
|
|
userName: "testuser",
|
|
displayName: "TestUser",
|
|
userId: "12345",
|
|
isMod: false,
|
|
isBroadcaster: false,
|
|
isVip: false,
|
|
isSubscriber: false,
|
|
},
|
|
id: "msg123",
|
|
});
|
|
|
|
expect(capturedMessage).not.toBeNull();
|
|
expect(capturedMessage?.username).toBe("testuser");
|
|
expect(capturedMessage?.displayName).toBe("TestUser");
|
|
expect(capturedMessage?.userId).toBe("12345");
|
|
expect(capturedMessage?.message).toBe("Hello bot!");
|
|
expect(capturedMessage?.channel).toBe("testchannel");
|
|
expect(capturedMessage?.chatType).toBe("group");
|
|
});
|
|
|
|
it("should normalize channel names without # prefix", async () => {
|
|
await manager.getClient(testAccount);
|
|
|
|
const onMessageCallback = messageHandlers[0];
|
|
|
|
onMessageCallback("testchannel", "testuser", "Test", {
|
|
userInfo: {
|
|
userName: "testuser",
|
|
displayName: "TestUser",
|
|
userId: "123",
|
|
isMod: false,
|
|
isBroadcaster: false,
|
|
isVip: false,
|
|
isSubscriber: false,
|
|
},
|
|
id: "msg1",
|
|
});
|
|
|
|
expect(capturedMessage?.channel).toBe("testchannel");
|
|
});
|
|
|
|
it("should include user role flags in message", async () => {
|
|
await manager.getClient(testAccount);
|
|
|
|
const onMessageCallback = messageHandlers[0];
|
|
|
|
onMessageCallback("#testchannel", "moduser", "Test", {
|
|
userInfo: {
|
|
userName: "moduser",
|
|
displayName: "ModUser",
|
|
userId: "456",
|
|
isMod: true,
|
|
isBroadcaster: false,
|
|
isVip: true,
|
|
isSubscriber: true,
|
|
},
|
|
id: "msg2",
|
|
});
|
|
|
|
expect(capturedMessage?.isMod).toBe(true);
|
|
expect(capturedMessage?.isVip).toBe(true);
|
|
expect(capturedMessage?.isSub).toBe(true);
|
|
expect(capturedMessage?.isOwner).toBe(false);
|
|
});
|
|
|
|
it("should handle broadcaster messages", async () => {
|
|
await manager.getClient(testAccount);
|
|
|
|
const onMessageCallback = messageHandlers[0];
|
|
|
|
onMessageCallback("#testchannel", "broadcaster", "Test", {
|
|
userInfo: {
|
|
userName: "broadcaster",
|
|
displayName: "Broadcaster",
|
|
userId: "789",
|
|
isMod: false,
|
|
isBroadcaster: true,
|
|
isVip: false,
|
|
isSubscriber: false,
|
|
},
|
|
id: "msg3",
|
|
});
|
|
|
|
expect(capturedMessage?.isOwner).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("should handle multiple message handlers for different accounts", async () => {
|
|
const messages1: TwitchChatMessage[] = [];
|
|
const messages2: TwitchChatMessage[] = [];
|
|
|
|
manager.onMessage(testAccount, (msg) => messages1.push(msg));
|
|
manager.onMessage(testAccount2, (msg) => messages2.push(msg));
|
|
|
|
await manager.getClient(testAccount);
|
|
await manager.getClient(testAccount2);
|
|
|
|
// Simulate message for first account
|
|
const onMessage1 = messageHandlers[0];
|
|
if (!onMessage1) throw new Error("onMessage1 not found");
|
|
onMessage1("#testchannel", "user1", "msg1", {
|
|
userInfo: {
|
|
userName: "user1",
|
|
displayName: "User1",
|
|
userId: "1",
|
|
isMod: false,
|
|
isBroadcaster: false,
|
|
isVip: false,
|
|
isSubscriber: false,
|
|
},
|
|
id: "1",
|
|
});
|
|
|
|
// Simulate message for second account
|
|
const onMessage2 = messageHandlers[1];
|
|
if (!onMessage2) throw new Error("onMessage2 not found");
|
|
onMessage2("#testchannel2", "user2", "msg2", {
|
|
userInfo: {
|
|
userName: "user2",
|
|
displayName: "User2",
|
|
userId: "2",
|
|
isMod: false,
|
|
isBroadcaster: false,
|
|
isVip: false,
|
|
isSubscriber: false,
|
|
},
|
|
id: "2",
|
|
});
|
|
|
|
expect(messages1).toHaveLength(1);
|
|
expect(messages2).toHaveLength(1);
|
|
expect(messages1[0]?.message).toBe("msg1");
|
|
expect(messages2[0]?.message).toBe("msg2");
|
|
});
|
|
|
|
it("should handle rapid client creation requests", async () => {
|
|
const promises = [
|
|
manager.getClient(testAccount),
|
|
manager.getClient(testAccount),
|
|
manager.getClient(testAccount),
|
|
];
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Note: The implementation doesn't handle concurrent getClient calls,
|
|
// so multiple connections may be created. This is expected behavior.
|
|
expect(mockConnect).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|