forked from claude-did-this/claude-hub
* fix: merge entrypoint scripts and fix auto-tagging tool permissions - Merged duplicate claudecode-entrypoint.sh and claudecode-tagging-entrypoint.sh scripts - Added dynamic tool selection based on OPERATION_TYPE environment variable - Fixed auto-tagging permissions to include required Bash(gh:*) commands - Removed 95% code duplication between entrypoint scripts - Simplified claudeService.ts to use unified entrypoint - Auto-tagging now uses: Read,GitHub,Bash(gh issue edit:*),Bash(gh issue view:*),Bash(gh label list:*) - General operations continue to use full tool set 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: update Dockerfile to use unified entrypoint script - Remove references to deleted claudecode-tagging-entrypoint.sh - Update build process to use single unified entrypoint script * fix: remove unnecessary async from promisify mock to fix lint error * feat: add Husky pre-commit hooks with Prettier as primary formatter - Added Husky for Git pre-commit hooks - Configured eslint-config-prettier to avoid ESLint/Prettier conflicts - Prettier handles all formatting, ESLint handles code quality only - Pre-commit hooks: Prettier format, ESLint check, TypeScript check - Updated documentation with pre-commit hook setup - All code quality issues resolved * feat: consolidate workflows and fix permission issues with clean Docker runners - Replace 3 complex workflows with 2 lean ones (pull-request.yml, main.yml) - Add Docker runner configuration for clean, isolated builds - Remove file permission hacks - use ephemeral containers instead - Split workload: GitHub-hosted for tests/security, self-hosted for Docker builds - Add comprehensive pre-commit configuration for security - Update documentation to be more pragmatic - Fix credential file permissions and security audit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: allow Husky prepare script to fail in production builds 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: update CI badge to reference new main.yml workflow 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
280 lines
8.3 KiB
TypeScript
280 lines
8.3 KiB
TypeScript
import request from 'supertest';
|
|
import express from 'express';
|
|
|
|
// Mock dependencies before imports
|
|
jest.mock('../../../src/services/claudeService');
|
|
jest.mock('../../../src/utils/logger');
|
|
|
|
const mockProcessCommand = jest.fn<() => Promise<string>>();
|
|
jest.mocked(require('../../../src/services/claudeService')).processCommand = mockProcessCommand;
|
|
|
|
interface MockLogger {
|
|
info: jest.Mock;
|
|
warn: jest.Mock;
|
|
error: jest.Mock;
|
|
debug: jest.Mock;
|
|
}
|
|
|
|
const mockLogger: MockLogger = {
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn()
|
|
};
|
|
jest.mocked(require('../../../src/utils/logger')).createLogger = jest.fn(() => mockLogger);
|
|
|
|
// Import router after mocks are set up
|
|
import claudeRouter from '../../../src/routes/claude';
|
|
|
|
describe('Claude Routes', () => {
|
|
let app: express.Application;
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
process.env = { ...originalEnv };
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use('/api/claude', claudeRouter);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
describe('POST /api/claude', () => {
|
|
it('should process valid Claude request with repository and command', async () => {
|
|
mockProcessCommand.mockResolvedValue('Claude response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
message: 'Command processed successfully',
|
|
response: 'Claude response'
|
|
});
|
|
|
|
expect(mockProcessCommand).toHaveBeenCalledWith({
|
|
repoFullName: 'owner/repo',
|
|
issueNumber: null,
|
|
command: 'Test command',
|
|
isPullRequest: false,
|
|
branchName: null
|
|
});
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
expect.objectContaining({ request: expect.any(Object) }),
|
|
'Received direct Claude request'
|
|
);
|
|
});
|
|
|
|
it('should handle repoFullName parameter as alternative to repository', async () => {
|
|
mockProcessCommand.mockResolvedValue('Claude response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repoFullName: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockProcessCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
repoFullName: 'owner/repo'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should process request with all optional parameters', async () => {
|
|
mockProcessCommand.mockResolvedValue('Claude response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command',
|
|
useContainer: true,
|
|
issueNumber: 42,
|
|
isPullRequest: true,
|
|
branchName: 'feature-branch'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockProcessCommand).toHaveBeenCalledWith({
|
|
repoFullName: 'owner/repo',
|
|
issueNumber: 42,
|
|
command: 'Test command',
|
|
isPullRequest: true,
|
|
branchName: 'feature-branch'
|
|
});
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
repo: 'owner/repo',
|
|
commandLength: 12,
|
|
useContainer: true,
|
|
issueNumber: 42,
|
|
isPullRequest: true
|
|
}),
|
|
'Processing direct Claude command'
|
|
);
|
|
});
|
|
|
|
it('should return 400 when repository is missing', async () => {
|
|
const response = await request(app).post('/api/claude').send({
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({
|
|
error: 'Repository name is required'
|
|
});
|
|
|
|
expect(mockLogger.warn).toHaveBeenCalledWith('Missing repository name in request');
|
|
expect(mockProcessCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 400 when command is missing', async () => {
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({
|
|
error: 'Command is required'
|
|
});
|
|
|
|
expect(mockLogger.warn).toHaveBeenCalledWith('Missing command in request');
|
|
expect(mockProcessCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should validate authentication when required', async () => {
|
|
process.env.CLAUDE_API_AUTH_REQUIRED = '1';
|
|
process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token';
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command',
|
|
authToken: 'wrong-token'
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({
|
|
error: 'Invalid authentication token'
|
|
});
|
|
|
|
expect(mockLogger.warn).toHaveBeenCalledWith('Invalid authentication token');
|
|
expect(mockProcessCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should accept valid authentication token', async () => {
|
|
process.env.CLAUDE_API_AUTH_REQUIRED = '1';
|
|
process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token';
|
|
mockProcessCommand.mockResolvedValue('Authenticated response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command',
|
|
authToken: 'secret-token'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.response).toBe('Authenticated response');
|
|
});
|
|
|
|
it('should skip authentication when not required', async () => {
|
|
process.env.CLAUDE_API_AUTH_REQUIRED = '0';
|
|
mockProcessCommand.mockResolvedValue('Response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it('should handle empty Claude response with default message', async () => {
|
|
mockProcessCommand.mockResolvedValue('');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.response).toBe(
|
|
'No output received from Claude container. This is a placeholder response.'
|
|
);
|
|
});
|
|
|
|
it('should handle whitespace-only Claude response', async () => {
|
|
mockProcessCommand.mockResolvedValue(' \n\t ');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.response).toBe(
|
|
'No output received from Claude container. This is a placeholder response.'
|
|
);
|
|
});
|
|
|
|
it('should handle Claude processing errors gracefully', async () => {
|
|
const error = new Error('Claude processing failed');
|
|
mockProcessCommand.mockRejectedValue(error);
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
message: 'Command processed successfully',
|
|
response: 'Error: Claude processing failed'
|
|
});
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error during Claude processing');
|
|
});
|
|
|
|
it('should log debug information about Claude response', async () => {
|
|
mockProcessCommand.mockResolvedValue('Test response content');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
{
|
|
responseType: 'string',
|
|
responseLength: 21
|
|
},
|
|
'Raw Claude response received'
|
|
);
|
|
});
|
|
|
|
it('should log successful completion', async () => {
|
|
mockProcessCommand.mockResolvedValue('Response');
|
|
|
|
const response = await request(app).post('/api/claude').send({
|
|
repository: 'owner/repo',
|
|
command: 'Test command'
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
{
|
|
responseLength: 8
|
|
},
|
|
'Successfully processed Claude command'
|
|
);
|
|
});
|
|
});
|
|
});
|