Skip to main content
Glama
build-step-manager.test.ts27.8 kB
/** * Tests for BuildStepManager * * Verifies build step management functionality including: * - Listing build steps * - Creating new build steps * - Updating existing steps * - Deleting steps * - Reordering steps * - Error handling */ import { BuildStepManager, type RunnerType } from '@/teamcity/build-step-manager'; import { BuildConfigurationNotFoundError, BuildStepNotFoundError, PermissionDeniedError, TeamCityAPIError, ValidationError, } from '@/teamcity/errors'; import { type MockBuildTypeApi, type MockTeamCityClient, createMockTeamCityClient, } from '../../test-utils/mock-teamcity-client'; describe('BuildStepManager', () => { let manager: BuildStepManager; let mockClient: MockTeamCityClient; let http: jest.Mocked<ReturnType<MockTeamCityClient['getAxios']>>; let buildTypesApi: MockBuildTypeApi; beforeEach(() => { mockClient = createMockTeamCityClient(); http = mockClient.http as jest.Mocked<ReturnType<MockTeamCityClient['getAxios']>>; http.get.mockReset(); http.post.mockReset(); http.put.mockReset(); http.delete.mockReset(); buildTypesApi = mockClient.mockModules.buildTypes; buildTypesApi.getAllBuildSteps.mockImplementation((configId: string, fields?: string) => http.get( fields ? `/app/rest/buildTypes/${configId}/steps?fields=${fields}` : `/app/rest/buildTypes/${configId}/steps` ) ); buildTypesApi.addBuildStepToBuildType.mockImplementation( (configId: string, _fields?: string, body?: unknown) => http.post(`/app/rest/buildTypes/${configId}/steps`, body) ); buildTypesApi.replaceBuildStep.mockImplementation( (configId: string, stepId: string, _fields?: string, body?: unknown) => http.put(`/app/rest/buildTypes/${configId}/steps/${stepId}`, body) ); buildTypesApi.deleteBuildStep.mockImplementation((configId: string, stepId: string) => http.delete(`/app/rest/buildTypes/${configId}/steps/${stepId}`) ); buildTypesApi.replaceAllBuildSteps.mockImplementation( (configId: string, _fields?: string, body?: unknown) => http.put(`/app/rest/buildTypes/${configId}/steps`, body) ); manager = new BuildStepManager(mockClient); }); describe('listBuildSteps', () => { const mockSteps = { count: 3, step: [ { id: 'RUNNER_1', name: 'Compile', type: 'Maven2', disabled: false, properties: { property: [ { name: 'goals', value: 'clean compile' }, { name: 'pomLocation', value: 'pom.xml' }, ], }, }, { id: 'RUNNER_2', name: 'Run Tests', type: 'simpleRunner', disabled: false, properties: { property: [ { name: 'script.content', value: 'npm test' }, { name: 'script.working.directory', value: './' }, ], }, }, { id: 'RUNNER_3', name: 'Package', type: 'gradle-runner', disabled: true, properties: { property: [ { name: 'gradle.tasks', value: 'build' }, { name: 'gradle.build.file', value: 'build.gradle' }, ], }, }, ], }; it('should list all build steps for a configuration', async () => { http.get.mockResolvedValue({ data: mockSteps }); const result = await manager.listBuildSteps({ configId: 'MyProject_Build', }); expect(result.success).toBe(true); expect(result.steps).toHaveLength(3); expect(result.steps[0]).toMatchObject({ id: 'RUNNER_1', name: 'Compile', type: 'Maven2', enabled: true, parameters: { goals: 'clean compile', pomLocation: 'pom.xml', }, }); }); it('should handle empty step list', async () => { http.get.mockResolvedValue({ data: { count: 0, step: [] } }); const result = await manager.listBuildSteps({ configId: 'MyProject_Build', }); expect(result.success).toBe(true); expect(result.steps).toEqual([]); }); it('should handle configuration not found error', async () => { http.get.mockRejectedValue({ response: { status: 404, data: { message: 'Build configuration not found' }, }, }); await expect( manager.listBuildSteps({ configId: 'NonExistent_Build', }) ).rejects.toThrow(BuildConfigurationNotFoundError); }); it('should handle permission denied error', async () => { http.get.mockRejectedValue({ response: { status: 403, data: { message: 'Access denied' }, }, }); await expect( manager.listBuildSteps({ configId: 'MyProject_Build', }) ).rejects.toThrow(PermissionDeniedError); }); it('throws when TeamCity returns a malformed step list', async () => { http.get.mockResolvedValue({ data: { step: 'invalid' } }); await expect( manager.listBuildSteps({ configId: 'MyProject_Build', }) ).rejects.toThrow(TeamCityAPIError); }); }); describe('createBuildStep', () => { const newStepResponse = { id: 'RUNNER_4', name: 'Deploy', type: 'Docker', disabled: false, properties: { property: [ { name: 'docker.command', value: 'push' }, { name: 'docker.image', value: 'myapp:latest' }, ], }, }; it('should create a command line step', async () => { http.post.mockResolvedValue({ data: newStepResponse }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Deploy', type: 'simpleRunner', properties: { 'script.content': 'echo "Deploying..."', 'script.working.directory': './', }, }); expect(result.success).toBe(true); expect(result.step).toMatchObject({ id: 'RUNNER_4', name: 'Deploy', }); }); it('should create a Maven step with validation', async () => { http.post.mockResolvedValue({ data: newStepResponse }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Build', type: 'Maven2', properties: { goals: 'clean package', pomLocation: 'pom.xml', 'maven.home': '/usr/local/maven', }, }); expect(result.success).toBe(true); }); it('throws when TeamCity returns a malformed step payload', async () => { http.post.mockResolvedValue({ data: { name: 'Deploy' } }); await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Deploy', type: 'simpleRunner', properties: { 'script.content': 'echo ok' }, }) ).rejects.toThrow(TeamCityAPIError); }); it('should validate required parameters for runner type', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Maven', type: 'Maven2', properties: { // Missing required 'goals' parameter pomLocation: 'pom.xml', }, }) ).rejects.toThrow(ValidationError); }); it('should reject invalid runner type', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid', type: 'InvalidRunner' as unknown as RunnerType, properties: {}, }) ).rejects.toThrow(ValidationError); }); // Comprehensive runner type tests describe('Runner Type Creation Tests', () => { it('should create a Gradle step with all parameters', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_5', name: 'Gradle Build', type: 'gradle-runner', properties: { property: [ { name: 'gradle.tasks', value: 'build test' }, { name: 'gradle.build.file', value: 'build.gradle' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Gradle Build', type: 'gradle-runner', properties: { 'gradle.tasks': 'build test', 'gradle.build.file': 'build.gradle', 'gradle.home': '/usr/local/gradle', 'gradle-wrapper.path': './gradlew', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('gradle-runner'); }); it('should create a Docker step with required parameters', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_6', name: 'Docker Build', type: 'Docker', properties: { property: [ { name: 'docker.command', value: 'build' }, { name: 'docker.image', value: 'myapp:latest' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Docker Build', type: 'Docker', properties: { 'docker.command': 'build', 'docker.image': 'myapp:latest', 'dockerfile.path': './Dockerfile', 'docker.push.enabled': 'true', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('Docker'); }); it('should create a .NET CLI step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_7', name: 'DotNet Test', type: 'dotnet', properties: { property: [ { name: 'dotnet.command', value: 'test' }, { name: 'dotnet.project', value: 'MyProject.csproj' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'DotNet Test', type: 'dotnet', properties: { 'dotnet.command': 'test', 'dotnet.project': 'MyProject.csproj', 'dotnet.configuration': 'Release', 'dotnet.verbosity': 'normal', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('dotnet'); }); it('should create an MSBuild step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_8', name: 'MSBuild Compile', type: 'MSBuild', properties: { property: [ { name: 'msbuild.project', value: 'Solution.sln' }, { name: 'msbuild.targets', value: 'Build' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'MSBuild Compile', type: 'MSBuild', properties: { 'msbuild.project': 'Solution.sln', 'msbuild.targets': 'Build', 'msbuild.configuration': 'Release', 'msbuild.platform': 'Any CPU', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('MSBuild'); }); it('should create a Node.js runner step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_9', name: 'Node Build', type: 'nodejs-runner', properties: { property: [ { name: 'nodejs.script', value: 'npm run build' }, { name: 'nodejs.workingDir', value: './' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Node Build', type: 'nodejs-runner', properties: { 'nodejs.script': 'npm run build', 'nodejs.workingDir': './', 'nodejs.npmCommand': 'run', 'nodejs.nodeVersion': '18', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('nodejs-runner'); }); it('should create a Python runner step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_10', name: 'Python Tests', type: 'python', properties: { property: [ { name: 'python.script', value: 'pytest tests/' }, { name: 'python.version', value: '3.9' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Python Tests', type: 'python', properties: { 'python.script': 'pytest tests/', 'python.version': '3.9', 'python.workingDir': './', 'python.virtualenv': 'venv', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('python'); }); it('should create a Rust cargo runner step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_11', name: 'Cargo Build', type: 'cargo', properties: { property: [ { name: 'cargo.command', value: 'build' }, { name: 'cargo.features', value: '--release' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Cargo Build', type: 'cargo', properties: { 'cargo.command': 'build', 'cargo.features': '--release', 'cargo.workingDir': './', 'cargo.target': 'x86_64-unknown-linux-gnu', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('cargo'); }); it('should create a Kotlin Script runner step', async () => { http.post.mockResolvedValue({ data: { id: 'RUNNER_12', name: 'Kotlin Script', type: 'kotlinScript', properties: { property: [ { name: 'kotlinScript.content', value: 'println("Building project")' }, { name: 'kotlinScript.compiler', value: 'kotlinc' }, ], }, }, }); const result = await manager.createBuildStep({ configId: 'MyProject_Build', name: 'Kotlin Script', type: 'kotlinScript', properties: { 'kotlinScript.content': 'println("Building project")', 'kotlinScript.compiler': 'kotlinc', 'kotlinScript.jvmTarget': '11', 'kotlinScript.workingDir': './', }, }); expect(result.success).toBe(true); expect(result.step?.type).toBe('kotlinScript'); }); // Validation tests for each runner type it('should validate Gradle runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Gradle', type: 'gradle-runner', properties: { // Missing required 'gradle.tasks' parameter 'gradle.build.file': 'build.gradle', }, }) ).rejects.toThrow(ValidationError); }); it('should validate Docker runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Docker', type: 'Docker', properties: { // Missing required 'docker.command' parameter 'docker.image': 'myapp:latest', }, }) ).rejects.toThrow(ValidationError); }); it('should validate .NET CLI runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid DotNet', type: 'dotnet', properties: { // Missing required 'dotnet.command' parameter 'dotnet.project': 'MyProject.csproj', }, }) ).rejects.toThrow(ValidationError); }); it('should validate MSBuild runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid MSBuild', type: 'MSBuild', properties: { // Missing required 'msbuild.project' parameter 'msbuild.targets': 'Build', }, }) ).rejects.toThrow(ValidationError); }); it('should validate Node.js runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Node', type: 'nodejs-runner', properties: { // Missing required 'nodejs.script' parameter 'nodejs.workingDir': './', }, }) ).rejects.toThrow(ValidationError); }); it('should validate Python runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Python', type: 'python', properties: { // Missing required 'python.script' parameter 'python.version': '3.9', }, }) ).rejects.toThrow(ValidationError); }); it('should validate Cargo runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Cargo', type: 'cargo', properties: { // Missing required 'cargo.command' parameter 'cargo.features': '--release', }, }) ).rejects.toThrow(ValidationError); }); it('should validate Kotlin Script runner required parameters', async () => { await expect( manager.createBuildStep({ configId: 'MyProject_Build', name: 'Invalid Kotlin', type: 'kotlinScript', properties: { // Missing required 'kotlinScript.content' parameter 'kotlinScript.compiler': 'kotlinc', }, }) ).rejects.toThrow(ValidationError); }); }); }); describe('updateBuildStep', () => { it('should update an existing build step', async () => { http.put.mockResolvedValue({ data: { id: 'RUNNER_1', name: 'Updated Compile', type: 'Maven2', properties: { property: [{ name: 'goals', value: 'clean compile test' }], }, }, }); const result = await manager.updateBuildStep({ configId: 'MyProject_Build', stepId: 'RUNNER_1', name: 'Updated Compile', properties: { goals: 'clean compile test', }, }); expect(result.success).toBe(true); expect(result.step).toBeDefined(); expect(result.step?.name).toBe('Updated Compile'); }); it('should handle step not found error', async () => { http.put.mockRejectedValue({ response: { status: 404, data: { message: 'Build step not found' }, }, }); await expect( manager.updateBuildStep({ configId: 'MyProject_Build', stepId: 'INVALID_STEP', name: 'Updated', }) ).rejects.toThrow(BuildStepNotFoundError); }); it('should enable/disable a step', async () => { http.put.mockResolvedValue({ data: { id: 'RUNNER_1', name: 'Test Step', type: 'simpleRunner', disabled: true, properties: { property: [], }, }, }); const result = await manager.updateBuildStep({ configId: 'MyProject_Build', stepId: 'RUNNER_1', enabled: false, }); expect(result.success).toBe(true); expect(result.step).toBeDefined(); expect(result.step?.enabled).toBe(false); }); }); describe('deleteBuildStep', () => { it('should delete a build step', async () => { http.delete.mockResolvedValue({ status: 204 }); const result = await manager.deleteBuildStep({ configId: 'MyProject_Build', stepId: 'RUNNER_3', }); expect(result.success).toBe(true); expect(result.message).toContain('deleted successfully'); }); it('should handle step not found error', async () => { http.delete.mockRejectedValue({ response: { status: 404, data: { message: 'Build step not found' }, }, }); await expect( manager.deleteBuildStep({ configId: 'MyProject_Build', stepId: 'INVALID_STEP', }) ).rejects.toThrow(BuildStepNotFoundError); }); it('should handle dependency conflict error', async () => { http.delete.mockRejectedValue({ response: { status: 409, data: { message: 'Step has dependencies' }, }, }); await expect( manager.deleteBuildStep({ configId: 'MyProject_Build', stepId: 'RUNNER_1', }) ).rejects.toThrow(TeamCityAPIError); }); }); describe('reorderBuildSteps', () => { it('should reorder build steps', async () => { // Mock the initial get call to list existing steps const existingSteps = { step: [ { id: 'RUNNER_1', name: 'Step 1', type: 'simpleRunner', properties: { property: [] } }, { id: 'RUNNER_2', name: 'Step 2', type: 'simpleRunner', properties: { property: [] } }, { id: 'RUNNER_3', name: 'Step 3', type: 'simpleRunner', properties: { property: [] } }, ], }; http.get.mockResolvedValue({ data: existingSteps }); const reorderedSteps = { step: [ { id: 'RUNNER_2', name: 'Step 2', type: 'simpleRunner', properties: { property: [] } }, { id: 'RUNNER_1', name: 'Step 1', type: 'simpleRunner', properties: { property: [] } }, { id: 'RUNNER_3', name: 'Step 3', type: 'simpleRunner', properties: { property: [] } }, ], }; http.put.mockResolvedValue({ data: reorderedSteps }); const result = await manager.reorderBuildSteps({ configId: 'MyProject_Build', stepOrder: ['RUNNER_2', 'RUNNER_1', 'RUNNER_3'], }); expect(result.success).toBe(true); expect(result.steps).toBeDefined(); expect(result.steps).toHaveLength(3); const firstStep = result.steps?.[0]; expect(firstStep?.id).toBe('RUNNER_2'); }); it('should validate step order matches existing steps', async () => { // First get existing steps http.get.mockResolvedValue({ data: { step: [ { id: 'RUNNER_1', name: 'Step 1', type: 'simpleRunner', properties: { property: [] } }, { id: 'RUNNER_2', name: 'Step 2', type: 'simpleRunner', properties: { property: [] } }, ], }, }); await expect( manager.reorderBuildSteps({ configId: 'MyProject_Build', stepOrder: ['RUNNER_2', 'RUNNER_999'], // Invalid step ID }) ).rejects.toThrow(ValidationError); }); }); describe('parseRunnerProperties', () => { it('should parse Maven runner properties', () => { const properties = { property: [ { name: 'goals', value: 'clean test' }, { name: 'pomLocation', value: 'pom.xml' }, { name: 'runnerArgs', value: '-DskipTests=false' }, ], }; type PrivateAPI = { parseRunnerProperties: (t: string, p: unknown) => Record<string, string>; }; const parsed = (manager as unknown as PrivateAPI).parseRunnerProperties('Maven2', properties); expect(parsed).toEqual({ goals: 'clean test', pomLocation: 'pom.xml', runnerArgs: '-DskipTests=false', }); }); it('should parse Docker runner properties', () => { const properties = { property: [ { name: 'docker.command', value: 'build' }, { name: 'docker.image', value: 'myapp:latest' }, { name: 'dockerfile.path', value: './Dockerfile' }, ], }; type PrivateAPI = { parseRunnerProperties: (t: string, p: unknown) => Record<string, string>; }; const parsed = (manager as unknown as PrivateAPI).parseRunnerProperties('Docker', properties); expect(parsed).toEqual({ 'docker.command': 'build', 'docker.image': 'myapp:latest', 'dockerfile.path': './Dockerfile', }); }); }); describe('validateRunnerParameters', () => { it('should validate command line runner parameters', () => { type PrivateAPI = { validateRunnerParameters: (t: string, p: Record<string, string>) => void; }; expect(() => { (manager as unknown as PrivateAPI).validateRunnerParameters('simpleRunner', { 'script.content': 'echo "Hello"', }); }).not.toThrow(); }); it('should reject Maven runner without required goals', () => { type PrivateAPI = { validateRunnerParameters: (t: string, p: Record<string, string>) => void; }; expect(() => { (manager as unknown as PrivateAPI).validateRunnerParameters('Maven2', { pomLocation: 'pom.xml', }); }).toThrow(ValidationError); }); it('should reject Gradle runner without required tasks', () => { type PrivateAPI = { validateRunnerParameters: (t: string, p: Record<string, string>) => void; }; expect(() => { (manager as unknown as PrivateAPI).validateRunnerParameters('gradle-runner', { 'gradle.build.file': 'build.gradle', }); }).toThrow(ValidationError); }); }); describe('error handling', () => { it('should handle network errors gracefully', async () => { http.get.mockRejectedValue(new Error('Network error')); await expect( manager.listBuildSteps({ configId: 'MyProject_Build', }) ).rejects.toThrow(TeamCityAPIError); }); it('should handle malformed API responses', async () => { http.get.mockResolvedValue({ data: null }); await expect( manager.listBuildSteps({ configId: 'MyProject_Build', }) ).rejects.toThrow(TeamCityAPIError); }); it('should handle authentication errors', async () => { http.get.mockRejectedValue({ response: { status: 401, data: { message: 'Authentication required' }, }, }); await expect( manager.listBuildSteps({ configId: 'MyProject_Build', }) ).rejects.toThrow(TeamCityAPIError); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server