Files
claude-hub/test/unit/routes/claude.test.ts
Cheffromspace 12e4589169 Fix: Merge entrypoint scripts and fix auto-tagging tool permissions (#146)
* 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>
2025-05-31 20:53:58 -05:00

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'
);
});
});
});