Files
claude-hub/test/unit/controllers/chatbotController.test.js
Jonathan Flatt 30401a93c6 feat: add repository and branch parameters to Discord chatbot
- Add required 'repo' parameter for repository specification
- Add optional 'branch' parameter (defaults to 'main')
- Implement extractRepoAndBranch() method in DiscordProvider
- Add repository validation in chatbotController
- Update parseWebhookPayload to include repo/branch context
- Enhanced error messages for missing repository parameter
- Updated all tests to handle new repo/branch fields
- Added comprehensive test coverage for new functionality

Discord slash command now requires:
/claude repo:owner/repository command:your-instruction
/claude repo:owner/repository branch:feature command:your-instruction

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-05-27 20:02:48 -05:00

374 lines
12 KiB
JavaScript

// Mock dependencies
jest.mock('../../../src/services/claudeService');
jest.mock('../../../src/providers/ProviderFactory');
jest.mock('../../../src/utils/logger', () => ({
createLogger: () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
})
}));
jest.mock('../../../src/utils/secureCredentials', () => ({
get: jest.fn(),
loadCredentials: jest.fn()
}));
// Set required environment variables for claudeService
process.env.BOT_USERNAME = 'testbot';
process.env.DEFAULT_AUTHORIZED_USER = 'testuser';
const chatbotController = require('../../../src/controllers/chatbotController');
const claudeService = require('../../../src/services/claudeService');
const providerFactory = require('../../../src/providers/ProviderFactory');
jest.mock('../../../src/utils/sanitize', () => ({
sanitizeBotMentions: jest.fn(msg => msg)
}));
describe('chatbotController', () => {
let req, res, mockProvider;
beforeEach(() => {
req = {
method: 'POST',
path: '/api/webhooks/chatbot/discord',
headers: {
'user-agent': 'Discord-Webhooks/1.0',
'content-type': 'application/json'
},
body: {}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
};
mockProvider = {
verifyWebhookSignature: jest.fn().mockReturnValue(true),
parseWebhookPayload: jest.fn(),
extractBotCommand: jest.fn(),
sendResponse: jest.fn().mockResolvedValue(),
getUserId: jest.fn(),
isUserAuthorized: jest.fn().mockReturnValue(true),
formatErrorMessage: jest.fn().mockReturnValue('🚫 **Error Processing Command**\n\n**Reference ID:** `test-error-id`\n**Time:** 2023-01-01T00:00:00.000Z\n\nPlease contact an administrator with the reference ID above.'),
getProviderName: jest.fn().mockReturnValue('DiscordProvider'),
getBotMention: jest.fn().mockReturnValue('@claude')
};
providerFactory.getProvider.mockReturnValue(mockProvider);
providerFactory.createFromEnvironment.mockResolvedValue(mockProvider);
providerFactory.getStats.mockReturnValue({
totalRegistered: 1,
totalInitialized: 1,
availableProviders: ['discord'],
initializedProviders: ['discord']
});
providerFactory.getAllProviders.mockReturnValue(new Map([['discord', mockProvider]]));
claudeService.processCommand.mockResolvedValue('Claude response');
jest.clearAllMocks();
});
describe('handleChatbotWebhook', () => {
it('should handle successful webhook with valid signature', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'command',
content: 'help me',
userId: 'user123',
username: 'testuser',
channelId: 'channel123',
repo: 'owner/test-repo',
branch: 'main'
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me',
originalMessage: 'help me'
});
mockProvider.getUserId.mockReturnValue('user123');
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(mockProvider.verifyWebhookSignature).toHaveBeenCalledWith(req);
expect(mockProvider.parseWebhookPayload).toHaveBeenCalledWith(req.body);
expect(claudeService.processCommand).toHaveBeenCalledWith({
repoFullName: 'owner/test-repo',
issueNumber: null,
command: 'help me',
isPullRequest: false,
branchName: 'main',
chatbotContext: {
provider: 'discord',
userId: 'user123',
username: 'testuser',
channelId: 'channel123',
guildId: undefined,
repo: 'owner/test-repo',
branch: 'main'
}
});
expect(mockProvider.sendResponse).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
message: 'Command processed successfully'
}));
});
it('should return 401 for invalid webhook signature', async () => {
mockProvider.verifyWebhookSignature.mockReturnValue(false);
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid webhook signature'
});
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should handle signature verification errors', async () => {
mockProvider.verifyWebhookSignature.mockImplementation(() => {
throw new Error('Signature verification failed');
});
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Signature verification failed',
message: 'Signature verification failed'
});
});
it('should handle immediate responses like Discord PING', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'ping',
shouldRespond: true,
responseData: { type: 1 }
});
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.json).toHaveBeenCalledWith({ type: 1 });
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should skip processing for unknown message types', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'unknown',
shouldRespond: false
});
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: 'Webhook received but no command detected'
});
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should skip processing when no bot command is found', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'command',
content: 'hello world',
userId: 'user123'
});
mockProvider.extractBotCommand.mockReturnValue(null);
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: 'Webhook received but no bot mention found'
});
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should handle unauthorized users', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'command',
content: 'help me',
userId: 'unauthorized_user',
username: 'baduser'
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me'
});
mockProvider.getUserId.mockReturnValue('unauthorized_user');
mockProvider.isUserAuthorized.mockReturnValue(false);
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
expect.anything(),
'❌ Sorry, only authorized users can trigger Claude commands.'
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: 'Unauthorized user - command ignored',
context: {
provider: 'discord',
userId: 'unauthorized_user'
}
});
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should handle missing repository parameter', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'command',
content: 'help me',
userId: 'user123',
username: 'testuser',
repo: null, // No repo provided
branch: null
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me'
});
mockProvider.getUserId.mockReturnValue('user123');
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Repository Required')
);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
error: 'Repository parameter is required'
}));
expect(claudeService.processCommand).not.toHaveBeenCalled();
});
it('should handle Claude service errors gracefully', async () => {
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'command',
content: 'help me',
userId: 'user123',
username: 'testuser',
repo: 'owner/test-repo',
branch: 'main'
});
mockProvider.extractBotCommand.mockReturnValue({
command: 'help me'
});
mockProvider.getUserId.mockReturnValue('user123');
claudeService.processCommand.mockRejectedValue(new Error('Claude service error'));
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(mockProvider.sendResponse).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('🚫 **Error Processing Command**')
);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
error: 'Failed to process command'
}));
});
it('should handle provider initialization failure', async () => {
providerFactory.getProvider.mockReturnValue(null);
providerFactory.createFromEnvironment.mockRejectedValue(new Error('Provider init failed'));
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Provider initialization failed',
message: 'Provider init failed'
});
});
it('should handle payload parsing errors', async () => {
mockProvider.parseWebhookPayload.mockImplementation(() => {
throw new Error('Invalid payload');
});
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid payload format',
message: 'Invalid payload'
});
});
it('should handle unexpected errors', async () => {
providerFactory.getProvider.mockImplementation(() => {
throw new Error('Unexpected error');
});
await chatbotController.handleChatbotWebhook(req, res, 'discord');
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'Provider initialization failed',
message: 'Unexpected error'
}));
});
});
describe('handleDiscordWebhook', () => {
it('should call handleChatbotWebhook with discord provider', async () => {
// Mock a simple provider response to avoid validation
mockProvider.parseWebhookPayload.mockReturnValue({
type: 'ping',
shouldRespond: true,
responseData: { type: 1 }
});
await chatbotController.handleDiscordWebhook(req, res);
expect(res.json).toHaveBeenCalledWith({ type: 1 });
expect(res.status).not.toHaveBeenCalledWith(400); // Should not trigger repo validation
});
});
describe('getProviderStats', () => {
it('should return provider statistics successfully', async () => {
await chatbotController.getProviderStats(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
stats: {
totalRegistered: 1,
totalInitialized: 1,
availableProviders: ['discord'],
initializedProviders: ['discord']
},
providers: {
discord: {
name: 'DiscordProvider',
initialized: true,
botMention: '@claude'
}
},
timestamp: expect.any(String)
});
});
it('should handle errors when getting stats', async () => {
providerFactory.getStats.mockImplementation(() => {
throw new Error('Stats error');
});
await chatbotController.getProviderStats(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Failed to get provider statistics',
message: 'Stats error'
});
});
});
});