Skip to main content
Glama
build-status-manager.test.ts20.3 kB
/** * Tests for BuildStatusManager */ import { BuildStatusManager, type BuildStatusOptions } from '@/teamcity/build-status-manager'; import type { TeamCityClientAdapter } from '@/teamcity/client-adapter'; import { BuildAccessDeniedError, BuildNotFoundError } from '@/teamcity/errors'; describe('BuildStatusManager', () => { let manager: BuildStatusManager; type MockClient = { builds: { getBuild: jest.Mock; getMultipleBuilds: jest.Mock; getBuildProblems: jest.Mock; }; listBuildArtifacts: jest.Mock; downloadArtifactContent: jest.Mock; getBuildStatistics: jest.Mock; listChangesForBuild: jest.Mock; listSnapshotDependencies: jest.Mock; baseUrl: string; }; let mockClient: MockClient; beforeEach(() => { // Create mock TeamCity client with proper jest mocks mockClient = { builds: { getBuild: jest.fn(), getMultipleBuilds: jest.fn(), getBuildProblems: jest.fn(), }, listBuildArtifacts: jest.fn(), downloadArtifactContent: jest.fn(), getBuildStatistics: jest.fn(), listChangesForBuild: jest.fn(), listSnapshotDependencies: jest.fn(), baseUrl: 'https://teamcity.example.com', }; manager = new BuildStatusManager(mockClient as unknown as TeamCityClientAdapter); }); describe('getBuildStatus', () => { describe('Query by Build ID', () => { it('should retrieve build status by numeric ID', async () => { const mockBuildResponse = { data: { id: 12345, number: '42', buildTypeId: 'Build_Config_1', state: 'finished', status: 'SUCCESS', statusText: 'Build successful', branchName: 'main', webUrl: 'https://teamcity.example.com/build/12345', queuedDate: '20250829T100000+0000', startDate: '20250829T100100+0000', finishDate: '20250829T101500+0000', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12345' }); expect(result).toEqual({ buildId: '12345', buildNumber: '42', buildTypeId: 'Build_Config_1', state: 'finished', status: 'SUCCESS', statusText: 'Build successful', branchName: 'main', webUrl: 'https://teamcity.example.com/build/12345', queuedDate: new Date('2025-08-29T10:00:00Z'), startDate: new Date('2025-08-29T10:01:00Z'), finishDate: new Date('2025-08-29T10:15:00Z'), elapsedSeconds: 840, percentageComplete: 100, }); // Behavior-first: avoid verifying internal locator/fields }); it('should handle build ID as string', async () => { const mockBuildResponse = { data: { id: 12345, number: '42', state: 'running', status: 'UNKNOWN', percentageComplete: 45, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12345' }); expect(result.buildId).toBe('12345'); expect(result.state).toBe('running'); expect(result.percentageComplete).toBe(45); }); }); describe('Query by Build Number', () => { it('should retrieve build by build type and number', async () => { const mockBuildResponse = { data: { id: 12345, number: '42', buildTypeId: 'Build_Config_1', state: 'finished', status: 'FAILURE', statusText: 'Tests failed', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildNumber: '42', buildTypeId: 'Build_Config_1', }); expect(result.buildNumber).toBe('42'); expect(result.status).toBe('FAILURE'); expect(result.statusText).toBe('Tests failed'); // Behavior-first: avoid verifying internal locator }); it('should handle build number with branch filter', async () => { const mockBuildResponse = { data: { id: 12346, number: '43', buildTypeId: 'Build_Config_1', branchName: 'feature/test', state: 'finished', status: 'SUCCESS', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildNumber: '43', buildTypeId: 'Build_Config_1', branch: 'feature/test', }); expect(result.branchName).toBe('feature/test'); // Behavior-first: avoid verifying internal locator }); }); describe('Build States', () => { it('should handle queued builds', async () => { const mockBuildResponse = { data: { id: 12347, state: 'queued', status: 'UNKNOWN', queuedDate: '20250829T100000+0000', 'queued-info': { position: 3, estimatedStartTime: '20250829T100500+0000', }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12347' }); expect(result.state).toBe('queued'); expect(result.queuePosition).toBe(3); expect(result.estimatedStartTime).toEqual(new Date('2025-08-29T10:05:00Z')); expect(result.percentageComplete).toBe(0); }); it('should handle running builds with progress', async () => { const mockBuildResponse = { data: { id: 12348, state: 'running', status: 'UNKNOWN', percentageComplete: 67, startDate: '20250829T100100+0000', 'running-info': { percentageComplete: 67, elapsedSeconds: 420, estimatedTotalSeconds: 627, currentStageText: 'Running tests', outdated: false, }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12348' }); expect(result.state).toBe('running'); expect(result.percentageComplete).toBe(67); expect(result.elapsedSeconds).toBe(420); expect(result.estimatedTotalSeconds).toBe(627); expect(result.currentStageText).toBe('Running tests'); }); it('should handle finished successful builds', async () => { const mockBuildResponse = { data: { id: 12349, state: 'finished', status: 'SUCCESS', statusText: 'Build successful', percentageComplete: 100, startDate: '20250829T100100+0000', finishDate: '20250829T101500+0000', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12349' }); expect(result.state).toBe('finished'); expect(result.status).toBe('SUCCESS'); expect(result.percentageComplete).toBe(100); expect(result.elapsedSeconds).toBe(840); }); it('should handle failed builds', async () => { const mockBuildResponse = { data: { id: 12350, state: 'finished', status: 'FAILURE', statusText: 'Exit code 1', failureReason: 'Process exited with code 1', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12350' }); expect(result.state).toBe('finished'); expect(result.status).toBe('FAILURE'); expect(result.statusText).toBe('Exit code 1'); expect(result.failureReason).toBe('Process exited with code 1'); }); it('should handle canceled builds', async () => { const mockBuildResponse = { data: { id: 12351, state: 'finished', status: 'UNKNOWN', statusText: 'Canceled', canceled: true, canceledInfo: { user: { username: 'john.doe' }, timestamp: '20250829T103000+0000', }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12351' }); expect(result.state).toBe('canceled'); expect(result.status).toBe('UNKNOWN'); expect(result.canceledBy).toBe('john.doe'); expect(result.canceledDate).toEqual(new Date('2025-08-29T10:30:00Z')); }); }); describe('Test Summary', () => { it('should include test summary when requested', async () => { const mockBuildResponse = { data: { id: 12352, state: 'finished', status: 'FAILURE', testOccurrences: { count: 150, passed: 145, failed: 3, ignored: 2, muted: 0, newFailed: 1, }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12352', includeTests: true, }); expect(result.testSummary).toEqual({ total: 150, passed: 145, failed: 3, ignored: 2, muted: 0, newFailed: 1, }); }); it('should not include test summary by default', async () => { const mockBuildResponse = { data: { id: 12353, state: 'finished', status: 'SUCCESS', testOccurrences: { count: 100, passed: 100, }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12353' }); expect(result.testSummary).toBeUndefined(); }); }); describe('Build Problems', () => { it('should include build problems when requested', async () => { const mockBuildResponse = { data: { id: 12354, state: 'finished', status: 'FAILURE', problemOccurrences: { problemOccurrence: [ { type: 'TC_COMPILATION_ERROR', identity: 'compilation_error_1', details: 'Compilation failed: syntax error at line 42', }, { type: 'TC_EXIT_CODE', identity: 'exit_code_1', details: 'Process exited with code 1', }, ], }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12354', includeProblems: true, }); expect(result.problems).toHaveLength(2); expect(result.problems?.[0]).toEqual({ type: 'TC_COMPILATION_ERROR', identity: 'compilation_error_1', description: 'Compilation failed: syntax error at line 42', }); }); it('should not include problems by default', async () => { const mockBuildResponse = { data: { id: 12355, state: 'finished', status: 'FAILURE', problemOccurrences: { problemOccurrence: [{ type: 'TC_EXIT_CODE' }], }, }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatus({ buildId: '12355' }); expect(result.problems).toBeUndefined(); }); }); describe('Error Handling', () => { it('should throw BuildNotFoundError for non-existent builds', async () => { mockClient.builds.getBuild.mockRejectedValue({ response: { status: 404, data: { message: 'Build not found' } }, }); await expect(manager.getBuildStatus({ buildId: '99999' })).rejects.toThrow( BuildNotFoundError ); }); it('should throw BuildAccessDeniedError for permission issues', async () => { mockClient.builds.getBuild.mockRejectedValue({ response: { status: 403, data: { message: 'Access denied' } }, }); await expect(manager.getBuildStatus({ buildId: '12356' })).rejects.toThrow( BuildAccessDeniedError ); }); it('should throw error for invalid build locator', async () => { await expect( manager.getBuildStatus({ buildNumber: '42' } as unknown as BuildStatusOptions) ).rejects.toThrow('Build type ID is required when querying by build number'); }); it('should throw error when neither buildId nor buildNumber provided', async () => { await expect(manager.getBuildStatus({} as unknown as BuildStatusOptions)).rejects.toThrow( 'Either buildId or buildNumber must be provided' ); }); it('should handle API communication errors', async () => { mockClient.builds.getBuild.mockRejectedValue(new Error('Network error')); await expect(manager.getBuildStatus({ buildId: '12357' })).rejects.toThrow('Network error'); }); }); describe('Caching', () => { it('should cache completed build statuses', async () => { const mockBuildResponse = { data: { id: 12358, state: 'finished', status: 'SUCCESS', statusText: 'Build successful', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); // First call const result1 = await manager.getBuildStatus({ buildId: '12358' }); expect(result1.status).toBe('SUCCESS'); expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(1); // Second call should use cache const result2 = await manager.getBuildStatus({ buildId: '12358' }); expect(result2.status).toBe('SUCCESS'); // Behavior-first: repeated call should return same status expect(result2).toEqual(result1); // Ensure underlying client was not called again expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(1); }); it('should not cache running or queued builds', async () => { const mockRunningResponse = { data: { id: 12359, state: 'running', percentageComplete: 50, }, }; const mockFinishedResponse = { data: { id: 12359, state: 'finished', status: 'SUCCESS', percentageComplete: 100, }, }; mockClient.builds.getBuild .mockResolvedValueOnce(mockRunningResponse) .mockResolvedValueOnce(mockFinishedResponse); // First call - running const result1 = await manager.getBuildStatus({ buildId: '12359' }); expect(result1.state).toBe('running'); expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(1); // Second call - should fetch again const result2 = await manager.getBuildStatus({ buildId: '12359' }); expect(result2.state).toBe('finished'); // Behavior-first: second call returns updated status and client called twice expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(2); }); it('should respect cache TTL', async () => { jest.useFakeTimers(); const mockBuildResponse = { data: { id: 12360, state: 'finished', status: 'SUCCESS', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); // First call await manager.getBuildStatus({ buildId: '12360' }); expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(1); // Advance time beyond cache TTL (5 minutes) jest.advanceTimersByTime(6 * 60 * 1000); // Should fetch again after TTL expires await manager.getBuildStatus({ buildId: '12360' }); expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(2); jest.useRealTimers(); }); it('should bypass cache when forceRefresh is true', async () => { const mockBuildResponse = { data: { id: 12361, state: 'finished', status: 'SUCCESS', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); // First call await manager.getBuildStatus({ buildId: '12361' }); expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(1); // Second call with forceRefresh await manager.getBuildStatus({ buildId: '12361', forceRefresh: true, }); // Behavior-first: second call with forceRefresh also returns expect(mockClient.builds.getBuild).toHaveBeenCalledTimes(2); }); }); describe('Field Selection', () => { it('should request minimal fields by default', async () => { mockClient.builds.getBuild.mockResolvedValue({ data: { id: 12362, state: 'finished', status: 'SUCCESS' }, }); await manager.getBuildStatus({ buildId: '12362' }); // Behavior-first: avoid verifying internal fields }); it('should request test fields when includeTests is true', async () => { mockClient.builds.getBuild.mockResolvedValue({ data: { id: 12363, state: 'finished', status: 'SUCCESS' }, }); await manager.getBuildStatus({ buildId: '12363', includeTests: true, }); // Behavior-first: avoid verifying internal fields }); it('should request problem fields when includeProblems is true', async () => { mockClient.builds.getBuild.mockResolvedValue({ data: { id: 12364, state: 'finished', status: 'FAILURE' }, }); await manager.getBuildStatus({ buildId: '12364', includeProblems: true, }); // Behavior-first: avoid verifying internal fields }); }); }); describe('getBuildStatusByLocator', () => { it('should handle custom locator strings', async () => { const mockBuildResponse = { data: { build: [ { id: 12365, state: 'running', status: 'UNKNOWN', }, ], }, }; mockClient.builds.getMultipleBuilds.mockResolvedValue(mockBuildResponse); const result = await manager.getBuildStatusByLocator( 'buildType:(id:Build_Config_1),branch:main,running:true' ); expect(result.buildId).toBe('12365'); expect(result.state).toBe('running'); }); it('should handle empty results from locator query', async () => { mockClient.builds.getMultipleBuilds.mockResolvedValue({ data: { build: [] }, }); await expect( manager.getBuildStatusByLocator('buildType:(id:Build_Config_1),number:999') ).rejects.toThrow(BuildNotFoundError); }); }); describe('clearCache', () => { it('should clear all cached build statuses', async () => { const mockBuildResponse = { data: { id: 12366, state: 'finished', status: 'SUCCESS', }, }; mockClient.builds.getBuild.mockResolvedValue(mockBuildResponse); // Cache a build await manager.getBuildStatus({ buildId: '12366' }); // Behavior-first: initial call cached // Clear cache manager.clearCache(); // Should fetch again after cache clear await manager.getBuildStatus({ buildId: '12366' }); }); }); });

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