Skip to main content
Glama
build-config-navigator.test.ts45.7 kB
/** * Tests for BuildConfigNavigator */ import { BuildConfigNavigator } from '@/teamcity/build-config-navigator'; import { type MockTeamCityClient, createMockTeamCityClient, } from '../../test-utils/mock-teamcity-client'; // Logger mocked below; direct import not required for behavior-first tests jest.mock('@/utils/logger', () => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), })); describe('BuildConfigNavigator', () => { let navigator: BuildConfigNavigator; let mockClient: MockTeamCityClient; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); // Create mock TeamCity client mockClient = createMockTeamCityClient(); mockClient.resetAllMocks(); navigator = new BuildConfigNavigator(mockClient); // Clear cache before each test without using `any` type PrivateNav = { cache: Map<string, unknown> }; (navigator as unknown as PrivateNav).cache.clear(); }); afterEach(() => { jest.useRealTimers(); }); describe('Basic Listing', () => { it('should fetch build configurations with no filters', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', description: 'Test build config', href: '/app/rest/buildTypes/id:Project1_Build', webUrl: 'https://teamcity.example.com/admin/editBuild.html?id=buildType:Project1_Build', }, { id: 'Project2_Test', name: 'Test Configuration', projectId: 'Project2', projectName: 'Project 2', description: 'Test configuration', href: '/app/rest/buildTypes/id:Project2_Test', webUrl: 'https://teamcity.example.com/admin/editBuild.html?id=buildType:Project2_Test', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(2); expect(result.buildConfigs[0]).toEqual({ id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', description: 'Test build config', href: '/app/rest/buildTypes/id:Project1_Build', webUrl: 'https://teamcity.example.com/admin/editBuild.html?id=buildType:Project1_Build', }); expect(result.totalCount).toBe(2); // Behavior-first: verify output content only }); it('should handle empty build configuration list', async () => { const mockResponse = { data: { count: 0, buildType: [], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(0); expect(result.totalCount).toBe(0); // Behavior-first: verify output content only }); }); describe('Project Filtering', () => { it('should filter build configurations by project ID', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', description: 'Test build config', href: '/app/rest/buildTypes/id:Project1_Build', webUrl: 'https://teamcity.example.com/admin/editBuild.html?id=buildType:Project1_Build', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ projectId: 'Project1' }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.projectId).toBe('Project1'); // Behavior-first: verify results are filtered as expected }); it('should handle multiple project IDs', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Build', name: 'Build Configuration 2', projectId: 'Project2', projectName: 'Project 2', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ projectIds: ['Project1', 'Project2'], }); expect(result.buildConfigs).toHaveLength(2); // Behavior-first: verify results contain both projects }); }); describe('Name Pattern Filtering', () => { it('should filter by build configuration name pattern', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Test', name: 'Test Configuration', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: 'Test*' }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.name).toBe('Test Configuration'); }); it('should support exact name matching', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project1_Build', name: 'Build Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Deploy', name: 'Deploy Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test Configuration', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: 'Deploy Configuration' }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.name).toBe('Deploy Configuration'); }); it('should support partial name matching', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project1_Build', name: 'Build Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Deploy', name: 'Deploy Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test Configuration', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: 'Config' }); expect(result.buildConfigs).toHaveLength(3); expect(result.buildConfigs.every((config) => config.name.includes('Configuration'))).toBe( true ); }); it('should support wildcard patterns at beginning and end', async () => { const mockResponse = { data: { count: 5, buildType: [ { id: 'Project1_FastBuild', name: 'Fast Build', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_SlowBuild', name: 'Slow Build', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_NightlyBuild', name: 'Nightly Build', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Deploy', name: 'Deploy', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: '*Build' }); expect(result.buildConfigs).toHaveLength(3); expect(result.buildConfigs.every((config) => config.name.endsWith('Build'))).toBe(true); }); it('should support complex wildcard patterns', async () => { const mockResponse = { data: { count: 5, buildType: [ { id: 'Project1_Dev_Deploy', name: 'Dev Deploy', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Staging_Deploy', name: 'Staging Deploy', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Prod_Deploy', name: 'Prod Deploy', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Dev_Build', name: 'Dev Build', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: '*Deploy' }); expect(result.buildConfigs).toHaveLength(3); expect(result.buildConfigs.every((config) => config.name.includes('Deploy'))).toBe(true); }); it('should be case-insensitive for name pattern matching', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Build', name: 'BUILD Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'build test', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: 'build*' }); expect(result.buildConfigs).toHaveLength(2); }); }); describe('Pagination', () => { it('should support pagination parameters', async () => { const mockResponse = { data: { count: 50, buildType: Array.from({ length: 10 }, (_, i) => ({ id: `Build${i + 1}`, name: `Build Configuration ${i + 1}`, projectId: 'TestProject', projectName: 'Test Project', })), }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ pagination: { limit: 10, offset: 0 }, }); expect(result.buildConfigs).toHaveLength(10); expect(result.totalCount).toBe(50); expect(result.hasMore).toBe(true); // Behavior-first: verify pagination metadata only }); it('should calculate hasMore correctly when at end of results', async () => { const mockResponse = { data: { count: 25, buildType: Array.from({ length: 5 }, (_, i) => ({ id: `Build${i + 21}`, name: `Build Configuration ${i + 21}`, projectId: 'TestProject', projectName: 'Test Project', })), }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ pagination: { limit: 10, offset: 20 }, }); expect(result.buildConfigs).toHaveLength(5); expect(result.totalCount).toBe(25); expect(result.hasMore).toBe(false); }); }); describe('Metadata Extraction', () => { it('should extract VCS root information when includeVcsRoots is true', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot1', 'vcs-root': { id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/repo.git' }, { name: 'branch', value: 'main' }, ], }, }, }, ], }, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ includeVcsRoots: true }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.vcsRoots).toBeDefined(); expect(result.buildConfigs[0]?.vcsRoots).toHaveLength(1); expect(result.buildConfigs[0]?.vcsRoots?.[0]).toEqual({ id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', url: 'https://github.com/example/repo.git', branch: 'main', }); }); it('should extract build parameters when includeParameters is true', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', parameters: { property: [ { name: 'env.NODE_ENV', value: 'production', type: { rawValue: 'text' }, }, { name: 'system.test.timeout', value: '30', type: { rawValue: 'text' }, }, ], }, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ includeParameters: true }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.parameters).toBeDefined(); expect(result.buildConfigs[0]?.parameters).toEqual({ 'env.NODE_ENV': 'production', 'system.test.timeout': '30', }); }); }); describe('Hierarchy Traversal', () => { it('should include project hierarchy information when includeProjectHierarchy is true', async () => { const mockBuildTypesResponse = { data: { count: 1, buildType: [ { id: 'SubProject_Build', name: 'Sub Project Build', projectId: 'SubProject', projectName: 'Sub Project', }, ], }, }; const mockProjectResponse = { data: { id: 'SubProject', name: 'Sub Project', parentProjectId: 'RootProject', parentProject: { id: 'RootProject', name: 'Root Project', }, }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockBuildTypesResponse); mockClient.projects.getProject.mockResolvedValue(mockProjectResponse); const result = await navigator.listBuildConfigs({ includeProjectHierarchy: true, }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.projectHierarchy).toBeDefined(); expect(result.buildConfigs[0]?.projectHierarchy).toEqual([ { id: 'RootProject', name: 'Root Project' }, { id: 'SubProject', name: 'Sub Project' }, ]); }); }); describe('Error Handling', () => { it('should handle 401 authentication errors', async () => { interface HttpError extends Error { response?: { status?: number; data?: unknown }; } const authError: HttpError = new Error('Authentication failed'); authError.response = { status: 401 }; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(authError); await expect(navigator.listBuildConfigs()).rejects.toThrow( 'Authentication failed - please check your TeamCity token' ); }); it('should handle 403 permission errors', async () => { interface HttpError extends Error { response?: { status?: number; data?: unknown }; } const permissionError: HttpError = new Error('Forbidden'); permissionError.response = { status: 403 }; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(permissionError); await expect(navigator.listBuildConfigs()).rejects.toThrow( 'Permission denied - you do not have access to build configurations' ); }); it('should handle 404 not found errors for specific projects', async () => { interface HttpError extends Error { response?: { status?: number; data?: unknown }; } const notFoundError: HttpError = new Error('Not found'); notFoundError.response = { status: 404 }; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(notFoundError); await expect(navigator.listBuildConfigs({ projectId: 'NonExistentProject' })).rejects.toThrow( 'Project NonExistentProject not found' ); }); it('should handle network timeouts', async () => { const timeoutError = new Error('Timeout'); timeoutError.name = 'ECONNABORTED'; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(timeoutError); await expect(navigator.listBuildConfigs()).rejects.toThrow( 'Request timed out - TeamCity server may be overloaded' ); }); it('should handle generic API errors', async () => { interface HttpError extends Error { response?: { status?: number; data?: unknown }; } const apiError: HttpError = new Error('Internal Server Error'); apiError.response = { status: 500, data: { message: 'Database error' } }; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(apiError); await expect(navigator.listBuildConfigs()).rejects.toThrow( 'TeamCity API error: Internal Server Error' ); }); it('should handle malformed API responses', async () => { const malformedResponse = { data: null, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(malformedResponse); await expect(navigator.listBuildConfigs()).rejects.toThrow( 'Invalid API response from TeamCity' ); }); }); describe('Caching', () => { it('should cache results for identical queries', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); // First call const result1 = await navigator.listBuildConfigs({ projectId: 'Project1' }); expect(mockClient.buildTypes.getAllBuildTypes).toHaveBeenCalledTimes(1); expect(result1.buildConfigs).toHaveLength(1); // Second identical call should return same results (behavior) const result2 = await navigator.listBuildConfigs({ projectId: 'Project1' }); // Verify cache prevented another client call expect(mockClient.buildTypes.getAllBuildTypes).toHaveBeenCalledTimes(1); expect(result2.buildConfigs).toEqual(result1.buildConfigs); }); it('should expire cache after TTL', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); // First call const first = await navigator.listBuildConfigs({ projectId: 'Project1' }); expect(first.buildConfigs).toHaveLength(1); // Advance time beyond cache TTL (120 seconds) jest.advanceTimersByTime(121000); // Second call after TTL should still return consistent results const second = await navigator.listBuildConfigs({ projectId: 'Project1' }); expect(second.buildConfigs).toHaveLength(1); }); it('should not cache error responses', async () => { const apiError = new Error('Server Error'); mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(apiError); // First call should fail await expect(navigator.listBuildConfigs()).rejects.toThrow('Server Error'); expect(mockClient.buildTypes.getAllBuildTypes).toHaveBeenCalledTimes(1); // Second call should also make API request (no caching of errors) await expect(navigator.listBuildConfigs()).rejects.toThrow('Server Error'); expect(mockClient.buildTypes.getAllBuildTypes).toHaveBeenCalledTimes(2); }); it('should clear old cache entries when cache limit is exceeded', async () => { const mockResponse = { data: { count: 1, buildType: [{ id: 'Build1', name: 'Build 1', projectId: 'Project1' }], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); // Fill cache with 101 entries to exceed the 100 entry limit /* eslint-disable no-await-in-loop */ for (let i = 0; i < 101; i++) { await navigator.listBuildConfigs({ projectId: `Project${i}` }); } /* eslint-enable no-await-in-loop */ // Behavior-first: ensure calls do not throw and produce results const sample = await navigator.listBuildConfigs({ projectId: 'Project100' }); expect(Array.isArray(sample.buildConfigs)).toBe(true); }); }); describe('View Modes', () => { it('should support list view mode (default)', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Build', name: 'Build Configuration 2', projectId: 'Project2', projectName: 'Project 2', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ viewMode: 'list' }); expect(result.viewMode).toBe('list'); expect(result.buildConfigs).toHaveLength(2); expect(result.groupedByProject).toBeUndefined(); }); it('should support project-grouped view mode', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Build', name: 'Build Configuration 2', projectId: 'Project2', projectName: 'Project 2', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ viewMode: 'project-grouped' }); expect(result.viewMode).toBe('project-grouped'); expect(result.groupedByProject).toBeDefined(); expect(result.groupedByProject).toHaveProperty('Project1'); expect(result.groupedByProject).toHaveProperty('Project2'); expect(result.groupedByProject?.['Project1']?.buildConfigs).toHaveLength(2); expect(result.groupedByProject?.['Project2']?.buildConfigs).toHaveLength(1); }); }); describe('Logging and Debugging', () => { it('should log successful API calls', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); await navigator.listBuildConfigs({ projectId: 'Project1' }); // Behavior-first: ensure no throw and results returned expect(true).toBe(true); }); it('should log cache hits', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration 1', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); // First call const result1 = await navigator.listBuildConfigs({ projectId: 'Project1' }); // Second call (cache hit) const result2 = await navigator.listBuildConfigs({ projectId: 'Project1' }); // Behavior-first: ensure second call still returns identical results expect(result2.buildConfigs).toEqual(result1.buildConfigs); }); it('should log errors with context', async () => { interface HttpError extends Error { response?: { status?: number; data?: unknown }; } const apiError: HttpError = new Error('Server Error'); apiError.response = { status: 500 }; mockClient.buildTypes.getAllBuildTypes.mockRejectedValue(apiError); await expect(navigator.listBuildConfigs({ projectId: 'Project1' })).rejects.toThrow(); // Behavior-first: ensure error surfaced to caller }); }); describe('Compound Filtering', () => { it('should apply multiple filters together', async () => { // When filtering by projectIds, the API should only return configs from those projects const mockResponse = { data: { count: 4, buildType: [ { id: 'Project1_Build', name: 'Build Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Deploy', name: 'Deploy Configuration', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Build', name: 'Build Configuration', projectId: 'Project2', projectName: 'Project 2', }, { id: 'Project2_Deploy', name: 'Deploy Configuration', projectId: 'Project2', projectName: 'Project 2', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ projectIds: ['Project1', 'Project2'], namePattern: 'Build*', }); expect(result.buildConfigs).toHaveLength(2); expect(result.buildConfigs[0]?.id).toBe('Project1_Build'); expect(result.buildConfigs[1]?.id).toBe('Project2_Build'); // Behavior-first: assert filtered result shape only }); it('should combine project filter with name pattern and pagination', async () => { const mockResponse = { data: { count: 10, buildType: Array.from({ length: 5 }, (_, i) => ({ id: `Project1_Deploy${i + 1}`, name: `Deploy Configuration ${i + 1}`, projectId: 'Project1', projectName: 'Project 1', })), }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ projectId: 'Project1', namePattern: 'Deploy*', pagination: { limit: 5, offset: 0 }, }); expect(result.buildConfigs).toHaveLength(5); expect(result.buildConfigs.every((config) => config.name.startsWith('Deploy'))).toBe(true); expect(result.buildConfigs.every((config) => config.projectId === 'Project1')).toBe(true); expect(result.hasMore).toBe(true); // Behavior-first: assert pagination flags only }); it('should apply all filters with metadata extraction', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Deploy', name: 'Deploy Configuration', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot1', 'vcs-root': { id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/repo.git' }, { name: 'branch', value: 'main' }, ], }, }, }, ], }, parameters: { property: [ { name: 'env', value: 'production' }, { name: 'version', value: '1.0.0' }, ], }, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); mockClient.projects.getProject.mockResolvedValue({ data: { id: 'Project1', name: 'Project 1', parentProject: { id: '_Root', name: '<Root project>', }, }, }); const result = await navigator.listBuildConfigs({ projectId: 'Project1', namePattern: 'Deploy*', includeVcsRoots: true, includeParameters: true, includeProjectHierarchy: true, viewMode: 'project-grouped', }); expect(result.buildConfigs).toHaveLength(1); const config = result.buildConfigs[0]; expect(config).toBeDefined(); if (!config) throw new Error('Expected config'); expect(config.vcsRoots).toBeDefined(); expect(config.vcsRoots).toHaveLength(1); expect(config.parameters).toBeDefined(); expect(config.parameters?.['env']).toBe('production'); expect(config.projectHierarchy).toBeDefined(); expect(config.projectHierarchy).toHaveLength(2); expect(result.viewMode).toBe('project-grouped'); expect(result.groupedByProject).toBeDefined(); }); }); describe('VCS Root Filtering', () => { it('should filter configurations by VCS root URL', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project1_Build', name: 'Build Configuration', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot1', 'vcs-root': { id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/repo.git' }, { name: 'branch', value: 'main' }, ], }, }, }, ], }, }, { id: 'Project1_Deploy', name: 'Deploy Configuration', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot2', 'vcs-root': { id: 'VcsRoot2', name: 'Deploy Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/deploy.git' }, { name: 'branch', value: 'production' }, ], }, }, }, ], }, }, { id: 'Project1_Test', name: 'Test Configuration', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot1', 'vcs-root': { id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/repo.git' }, { name: 'branch', value: 'develop' }, ], }, }, }, ], }, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ includeVcsRoots: true, }); expect(result.buildConfigs).toHaveLength(3); // Verify VCS roots are extracted expect(result.buildConfigs[0]?.vcsRoots).toBeDefined(); expect(result.buildConfigs[0]?.vcsRoots?.[0]?.url).toBe( 'https://github.com/example/repo.git' ); expect(result.buildConfigs[1]?.vcsRoots?.[0]?.url).toBe( 'https://github.com/example/deploy.git' ); }); it('should handle configurations with multiple VCS roots', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_MultiRepo', name: 'Multi-Repository Build', projectId: 'Project1', projectName: 'Project 1', 'vcs-root-entries': { 'vcs-root-entry': [ { id: 'VcsRoot1', 'vcs-root': { id: 'VcsRoot1', name: 'Main Repository', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/main.git' }, { name: 'branch', value: 'main' }, ], }, }, }, { id: 'VcsRoot2', 'vcs-root': { id: 'VcsRoot2', name: 'Shared Libraries', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/libs.git' }, { name: 'branch', value: 'stable' }, ], }, }, }, { id: 'VcsRoot3', 'vcs-root': { id: 'VcsRoot3', name: 'Configuration', vcsName: 'git', properties: { property: [ { name: 'url', value: 'https://github.com/example/config.git' }, { name: 'branch', value: 'master' }, ], }, }, }, ], }, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ includeVcsRoots: true, }); expect(result.buildConfigs).toHaveLength(1); const vcsRoots = result.buildConfigs[0]?.vcsRoots ?? []; expect(vcsRoots).toHaveLength(3); expect(vcsRoots[0]?.name).toBe('Main Repository'); expect(vcsRoots[1]?.name).toBe('Shared Libraries'); expect(vcsRoots[2]?.name).toBe('Configuration'); }); }); describe('Result Sorting', () => { it('should maintain server-provided order by default', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project3_Build', name: 'C Build', projectId: 'Project3', projectName: 'Project 3', }, { id: 'Project1_Build', name: 'A Build', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Build', name: 'B Build', projectId: 'Project2', projectName: 'Project 2', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(3); expect(result.buildConfigs[0]?.name).toBe('C Build'); expect(result.buildConfigs[1]?.name).toBe('A Build'); expect(result.buildConfigs[2]?.name).toBe('B Build'); }); it('should sort configurations in project-grouped view mode', async () => { const mockResponse = { data: { count: 4, buildType: [ { id: 'Project2_Build', name: 'Build', projectId: 'Project2', projectName: 'Project 2', }, { id: 'Project1_Deploy', name: 'Deploy', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project2_Test', name: 'Test', projectId: 'Project2', projectName: 'Project 2', }, { id: 'Project1_Build', name: 'Build', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ viewMode: 'project-grouped', }); expect(result.viewMode).toBe('project-grouped'); expect(result.groupedByProject).toBeDefined(); expect(Object.keys(result.groupedByProject ?? {})).toHaveLength(2); expect(result.groupedByProject?.['Project1']?.buildConfigs).toHaveLength(2); expect(result.groupedByProject?.['Project2']?.buildConfigs).toHaveLength(2); }); }); describe('Build Status and Activity Filtering', () => { it('should handle build configurations with recent activity metadata', async () => { const mockResponse = { data: { count: 3, buildType: [ { id: 'Project1_Active', name: 'Active Build', projectId: 'Project1', projectName: 'Project 1', lastBuildDate: '2025-08-30T10:00:00Z', lastBuildStatus: 'SUCCESS', }, { id: 'Project1_Inactive', name: 'Inactive Build', projectId: 'Project1', projectName: 'Project 1', lastBuildDate: '2025-01-01T10:00:00Z', lastBuildStatus: 'FAILURE', }, { id: 'Project1_Never', name: 'Never Built', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(3); // The navigator currently passes through all configs // Build status filtering would need to be implemented }); it('should handle paused build configurations', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Active', name: 'Active Build', projectId: 'Project1', projectName: 'Project 1', paused: false, }, { id: 'Project1_Paused', name: 'Paused Build', projectId: 'Project1', projectName: 'Project 1', paused: true, }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(2); // The navigator currently passes through all configs // Paused filtering would need to be implemented }); }); describe('Edge Cases and Advanced Scenarios', () => { it('should handle special characters in name patterns', async () => { const mockResponse = { data: { count: 2, buildType: [ { id: 'Project1_Build', name: 'Build [Production]', projectId: 'Project1', projectName: 'Project 1', }, { id: 'Project1_Test', name: 'Test (Development)', projectId: 'Project1', projectName: 'Project 1', }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ namePattern: '[Production]' }); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.name).toBe('Build [Production]'); }); it('should handle empty project IDs array', async () => { const mockResponse = { data: { count: 0, buildType: [], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs({ projectIds: [] }); expect(result.buildConfigs).toHaveLength(0); // Behavior-first: verify outputs only }); it('should handle configurations with missing metadata gracefully', async () => { const mockResponse = { data: { count: 1, buildType: [ { id: 'Project1_Build', name: 'Build Configuration', // Missing projectId and projectName }, ], }, }; mockClient.buildTypes.getAllBuildTypes.mockResolvedValue(mockResponse); const result = await navigator.listBuildConfigs(); expect(result.buildConfigs).toHaveLength(1); expect(result.buildConfigs[0]?.id).toBe('Project1_Build'); expect(result.buildConfigs[0]?.projectId).toBe(''); expect(result.buildConfigs[0]?.projectName).toBe(''); }); }); });

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