Skip to main content
Glama
artifact-manager.test.ts21.4 kB
/** * Tests for ArtifactManager */ import { Readable } from 'node:stream'; import type { RawAxiosRequestConfig } from 'axios'; import { ArtifactManager } from '@/teamcity/artifact-manager'; import { type MockBuildApi, type MockTeamCityClient, createMockTeamCityClient, } from '../../test-utils/mock-teamcity-client'; describe('ArtifactManager', () => { let manager: ArtifactManager; let mockClient: MockTeamCityClient; let http: jest.Mocked<ReturnType<MockTeamCityClient['getAxios']>>; let buildsApi: MockBuildApi; const BASE_URL = 'https://teamcity.example.com'; const configureClient = () => { mockClient = createMockTeamCityClient(); http = mockClient.http as jest.Mocked<ReturnType<MockTeamCityClient['getAxios']>>; http.get.mockReset(); buildsApi = mockClient.mockModules.builds; buildsApi.getFilesListOfBuild.mockImplementation((buildLocator: string) => http.get(`/app/rest/builds/${buildLocator}/artifacts`) ); buildsApi.downloadFileOfBuild.mockImplementation( ( path: string, buildLocator: string, _locatorFields?: unknown, _options?: unknown, config?: RawAxiosRequestConfig ) => http.get(`/app/rest/builds/${buildLocator}/artifacts/${path}`, config) ); mockClient.downloadArtifactContent.mockImplementation( async (buildId: string, artifactPath: string, requestConfig?: RawAxiosRequestConfig) => http.get(`/app/rest/builds/id:${buildId}/artifacts/content/${artifactPath}`, requestConfig) ); mockClient.request.mockImplementation(async (fn) => fn({ axios: http, baseUrl: BASE_URL })); mockClient.getApiConfig.mockReturnValue({ baseUrl: BASE_URL, token: 'test-token', timeout: undefined, }); mockClient.getConfig.mockReturnValue({ connection: { baseUrl: BASE_URL, token: 'test-token', timeout: undefined, }, }); manager = new ArtifactManager(mockClient); jest .spyOn(manager as unknown as { delay: (ms: number) => Promise<void> }, 'delay') .mockResolvedValue(); }; beforeEach(() => { configureClient(); }); describe('Artifact Listing', () => { it('should list all artifacts for a build', async () => { const mockArtifacts = { file: [ { name: 'app.jar', fullName: 'target/app.jar', size: 10485760, modificationTime: '20250829T121400+0000', href: '/app/rest/builds/id:12345/artifacts/metadata/target/app.jar', content: { href: '/app/rest/builds/id:12345/artifacts/content/target/app.jar' }, }, { name: 'test-report.html', fullName: 'reports/test-report.html', size: 524288, modificationTime: '20250829T121430+0000', href: '/app/rest/builds/id:12345/artifacts/metadata/reports/test-report.html', content: { href: '/app/rest/builds/id:12345/artifacts/content/reports/test-report.html', }, }, ], }; http.get.mockResolvedValue({ data: mockArtifacts }); const result = await manager.listArtifacts('12345'); // Behavior-first: avoid asserting internal HTTP call shape expect(result).toHaveLength(2); expect(result[0]?.name).toBe('app.jar'); expect(result[0]?.path).toBe('target/app.jar'); expect(result[0]?.size).toBe(10485760); expect(result[0]?.downloadUrl).toContain('/artifacts/content/target/app.jar'); }); it('should handle nested artifact directories', async () => { const mockArtifacts = { file: [ { name: 'libs', fullName: 'build/libs', size: 0, href: '/app/rest/builds/id:12345/artifacts/metadata/build/libs', children: { file: [ { name: 'core.jar', fullName: 'build/libs/core.jar', size: 2097152, }, { name: 'utils.jar', fullName: 'build/libs/utils.jar', size: 1048576, }, ], }, }, ], }; http.get.mockResolvedValue({ data: mockArtifacts }); const result = await manager.listArtifacts('12345', { includeNested: true }); expect(result).toHaveLength(2); expect(result[0]?.path).toBe('build/libs/core.jar'); expect(result[1]?.path).toBe('build/libs/utils.jar'); }); it('should paginate large artifact lists', async () => { const mockArtifacts = { file: new Array(150).fill(null).map((_, i) => ({ name: `file${i}.txt`, fullName: `files/file${i}.txt`, size: 1024, modificationTime: '20250829T120000+0000', })), nextHref: '/app/rest/builds/id:12345/artifacts?start=100', }; http.get.mockResolvedValue({ data: mockArtifacts }); const result = await manager.listArtifacts('12345', { limit: 100, offset: 0, }); expect(result).toHaveLength(100); expect(result[0]?.name).toBe('file0.txt'); expect(result[99]?.name).toBe('file99.txt'); }); it('throws when artifact listing payload is malformed', async () => { http.get.mockResolvedValue({ data: { file: 'oops' } }); await expect(manager.listArtifacts('12345')).rejects.toThrow('non-array file field'); }); }); describe('Artifact Filtering', () => { const mockArtifacts = { file: [ { name: 'app.jar', fullName: 'target/app.jar', size: 10485760 }, { name: 'lib.jar', fullName: 'target/lib.jar', size: 2097152 }, { name: 'test-report.html', fullName: 'reports/test-report.html', size: 524288 }, { name: 'coverage.xml', fullName: 'reports/coverage.xml', size: 102400 }, { name: 'README.md', fullName: 'README.md', size: 5120 }, ], }; beforeEach(() => { http.get.mockResolvedValue({ data: mockArtifacts }); }); it('should filter artifacts by name pattern', async () => { const result = await manager.listArtifacts('12345', { nameFilter: '*.jar', }); expect(result).toHaveLength(2); expect(result[0]?.name).toBe('app.jar'); expect(result[1]?.name).toBe('lib.jar'); }); it('should filter artifacts by path prefix', async () => { const result = await manager.listArtifacts('12345', { pathFilter: 'reports/*', }); expect(result).toHaveLength(2); expect(result[0]?.name).toBe('test-report.html'); expect(result[1]?.name).toBe('coverage.xml'); }); it('should filter artifacts by extension', async () => { const result = await manager.listArtifacts('12345', { extension: 'jar', }); expect(result).toHaveLength(2); expect(result.every((a) => a.name.endsWith('.jar'))).toBe(true); }); it('should filter artifacts by size range', async () => { const result = await manager.listArtifacts('12345', { minSize: 100000, maxSize: 1000000, }); expect(result).toHaveLength(2); expect(result[0]?.name).toBe('test-report.html'); expect(result[1]?.name).toBe('coverage.xml'); }); it('should combine multiple filters', async () => { const result = await manager.listArtifacts('12345', { pathFilter: 'reports/*', extension: 'xml', }); expect(result).toHaveLength(1); expect(result[0]?.name).toBe('coverage.xml'); }); }); describe('Artifact Download', () => { it('should generate download URLs', async () => { const mockArtifacts = { file: [{ name: 'app.jar', fullName: 'target/app.jar', size: 10485760 }], }; http.get.mockResolvedValue({ data: mockArtifacts }); const result = await manager.listArtifacts('12345'); expect(result[0]?.downloadUrl).toBe( 'https://teamcity.example.com/app/rest/builds/id:12345/artifacts/content/target/app.jar' ); }); it('should download artifact content as base64', async () => { const mockContent = 'Hello, World!'; const base64Content = Buffer.from(mockContent).toString('base64'); http.get .mockResolvedValueOnce({ data: { file: [{ name: 'hello.txt', fullName: 'hello.txt', size: 13 }], }, }) .mockResolvedValueOnce({ data: Buffer.from(mockContent), headers: { 'content-type': 'text/plain' }, }); const result = await manager.downloadArtifact('12345', 'hello.txt', { encoding: 'base64', }); expect(result.content).toBe(base64Content); expect(result.mimeType).toBe('text/plain'); expect(result.size).toBe(13); }); it('downloads nested directory artifacts even when TeamCity omits parent prefixes', async () => { const listingResponse = { file: [ { name: 'production', fullName: 'production', size: 0, children: { file: [ { name: 'web', fullName: 'web', size: 0, children: { file: [ { name: 'health.json', fullName: 'health.json', size: 20, }, ], }, }, ], }, }, ], }; const artifactContent = JSON.stringify({ status: 'ok' }); const expectedPath = 'production/web/health.json'; const artifactBuffer = Buffer.from(artifactContent); http.get.mockImplementation((path: string) => { if (typeof path === 'string' && path.includes('/artifacts/content/')) { return Promise.resolve({ data: artifactBuffer, headers: { 'content-type': 'application/json' }, }); } return Promise.resolve({ data: listingResponse }); }); const result = await manager.downloadArtifact('3711', expectedPath, { encoding: 'base64', }); expect(result.path).toBe(expectedPath); expect(result.content).toBe(artifactBuffer.toString('base64')); expect(http.get).toHaveBeenLastCalledWith( `/app/rest/builds/id:3711/artifacts/content/${expectedPath}`, expect.objectContaining({ responseType: 'arraybuffer' }) ); }); it('should download artifact content as text', async () => { const mockContent = 'Hello, World!'; http.get .mockResolvedValueOnce({ data: { file: [{ name: 'hello.txt', fullName: 'hello.txt', size: 13 }], }, }) .mockResolvedValueOnce({ data: mockContent, headers: { 'content-type': 'text/plain' }, }); const result = await manager.downloadArtifact('12345', 'hello.txt', { encoding: 'text', }); expect(result.content).toBe(mockContent); }); it('throws when text downloads return a non-string payload', async () => { http.get .mockResolvedValueOnce({ data: { file: [{ name: 'broken.txt', fullName: 'broken.txt', size: 1 }], }, }) .mockResolvedValueOnce({ data: 1234, }); await expect( manager.downloadArtifact('12345', 'broken.txt', { encoding: 'text', }) ).rejects.toThrow('non-text payload'); }); it('should handle binary artifacts', async () => { const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG header http.get .mockResolvedValueOnce({ data: { file: [{ name: 'image.png', fullName: 'image.png', size: 4 }], }, }) .mockResolvedValueOnce({ data: binaryData, headers: { 'content-type': 'image/png' }, }); const result = await manager.downloadArtifact('12345', 'image.png', { encoding: 'base64', }); expect(result.content).toBe(binaryData.toString('base64')); expect(result.mimeType).toBe('image/png'); }); it('should download artifact content as a stream when requested', async () => { const stream = Readable.from(['chunk-1', 'chunk-2']); http.get .mockResolvedValueOnce({ data: { file: [{ name: 'log.txt', fullName: 'logs/log.txt', size: 8 }], }, }) .mockResolvedValueOnce({ data: stream, headers: { 'content-type': 'text/plain' }, }); const result = await manager.downloadArtifact('12345', 'logs/log.txt', { encoding: 'stream', }); expect(result.content).toBe(stream); expect(result.mimeType).toBe('text/plain'); expect(result.size).toBe(8); expect(http.get).toHaveBeenLastCalledWith( '/app/rest/builds/id:12345/artifacts/content/logs/log.txt', expect.objectContaining({ responseType: 'stream' }) ); }); it('throws when stream downloads return non-stream payloads', async () => { http.get .mockResolvedValueOnce({ data: { file: [{ name: 'log.txt', fullName: 'logs/log.txt', size: 8 }], }, }) .mockResolvedValueOnce({ data: { not: 'a stream' }, }); await expect( manager.downloadArtifact('12345', 'logs/log.txt', { encoding: 'stream', }) ).rejects.toThrow('non-stream payload'); }); it('throws when binary downloads return unsupported payloads', async () => { http.get .mockResolvedValueOnce({ data: { file: [{ name: 'image.png', fullName: 'image.png', size: 4 }], }, }) .mockResolvedValueOnce({ data: { unexpected: true }, }); await expect( manager.downloadArtifact('12345', 'image.png', { encoding: 'base64', }) ).rejects.toThrow('unexpected binary payload'); }); it('should respect size limits', async () => { http.get.mockResolvedValueOnce({ data: { file: [{ name: 'large.bin', fullName: 'large.bin', size: 10485760 }], }, }); await expect( manager.downloadArtifact('12345', 'large.bin', { maxSize: 1048576, // 1MB limit }) ).rejects.toThrow('Artifact size exceeds maximum allowed size'); }); }); describe('Batch Operations', () => { beforeEach(() => { configureClient(); }); it('should download multiple artifacts', async () => { const mockArtifacts = { file: [ { name: 'file1.txt', fullName: 'file1.txt', size: 10 }, { name: 'file2.txt', fullName: 'file2.txt', size: 20 }, { name: 'file3.txt', fullName: 'file3.txt', size: 30 }, ], }; // The manager now downloads sequentially, caching the initial artifact list http.get .mockResolvedValueOnce({ data: mockArtifacts }) // listArtifacts (cached for subsequent downloads) .mockResolvedValueOnce({ data: Buffer.from('content1'), headers: {} }) // download file1 .mockResolvedValueOnce({ data: Buffer.from('content2'), headers: {} }) // download file2 .mockResolvedValueOnce({ data: Buffer.from('content3'), headers: {} }); // download file3 const result = await manager.downloadMultipleArtifacts('12345', [ 'file1.txt', 'file2.txt', 'file3.txt', ]); expect(result).toHaveLength(3); expect(result[0]?.name).toBe('file1.txt'); expect(result[0]?.content).toBe(Buffer.from('content1').toString('base64')); expect(result[1]?.name).toBe('file2.txt'); expect(result[1]?.content).toBe(Buffer.from('content2').toString('base64')); expect(result[2]?.name).toBe('file3.txt'); expect(result[2]?.content).toBe(Buffer.from('content3').toString('base64')); }); it('should stream multiple artifacts when requested', async () => { const mockArtifacts = { file: [ { name: 'logs/app.log', fullName: 'logs/app.log', size: 10 }, { name: 'metrics.json', fullName: 'metrics.json', size: 20 }, ], }; const streamOne = Readable.from(['chunk-1']); const streamTwo = Readable.from(['chunk-2']); http.get .mockResolvedValueOnce({ data: mockArtifacts }) .mockResolvedValueOnce({ data: streamOne, headers: { 'content-type': 'text/plain' } }) .mockResolvedValueOnce({ data: streamTwo, headers: { 'content-type': 'application/json' }, }); const result = await manager.downloadMultipleArtifacts( '12345', ['logs/app.log', 'metrics.json'], { encoding: 'stream' } ); expect(result).toHaveLength(2); expect(result[0]?.content).toBe(streamOne); expect(result[0]?.mimeType).toBe('text/plain'); expect(result[1]?.content).toBe(streamTwo); expect(result[1]?.mimeType).toBe('application/json'); }); it('should handle partial batch download failures', async () => { const mockArtifacts = { file: [ { name: 'file1.txt', fullName: 'file1.txt', size: 10 }, { name: 'file2.txt', fullName: 'file2.txt', size: 20 }, ], }; http.get .mockResolvedValueOnce({ data: mockArtifacts }) // listArtifacts cached for both downloads .mockResolvedValueOnce({ data: Buffer.from('content1'), headers: {} }) // download file1 .mockRejectedValueOnce(new Error('Download failed')); // download file2 fails const result = await manager.downloadMultipleArtifacts('12345', ['file1.txt', 'file2.txt']); expect(result).toHaveLength(2); expect(result[0]?.content).toBe(Buffer.from('content1').toString('base64')); expect(result[1]?.error).toBe('Failed to download artifact: Download failed'); }); }); describe('Error Handling', () => { beforeEach(() => { configureClient(); }); it('should handle network errors', async () => { http.get.mockRejectedValue(new Error('Network error')); await expect(manager.listArtifacts('12345')).rejects.toThrow('Failed to fetch artifacts'); }); it('should handle authentication errors', async () => { const error = new Error('Unauthorized') as Error & { response?: { status: number; data: string }; }; error.response = { status: 401, data: 'Unauthorized' }; http.get.mockRejectedValue(error); await expect(manager.listArtifacts('12345')).rejects.toThrow('Authentication failed'); }); it('should handle missing artifacts', async () => { http.get.mockResolvedValue({ data: { file: [] } }); const result = await manager.listArtifacts('12345'); expect(result).toEqual([]); }); it('should handle artifact not found during download', async () => { http.get.mockImplementation(async () => ({ data: { file: [] }, })); await expect(manager.downloadArtifact('12345', 'nonexistent.txt')).rejects.toThrow( 'Artifact not found' ); }); }); describe('Caching', () => { it('should cache artifact listings', async () => { const mockArtifacts = { file: [{ name: 'app.jar', fullName: 'target/app.jar', size: 10485760 }], }; http.get.mockResolvedValue({ data: mockArtifacts }); // First call const first = await manager.listArtifacts('12345'); expect(http.get).toHaveBeenCalledTimes(1); // Second call should produce same results const second = await manager.listArtifacts('12345'); expect(second).toEqual(first); // Ensure cache prevented additional HTTP call expect(http.get).toHaveBeenCalledTimes(1); }); it('should respect cache TTL', async () => { jest.useFakeTimers(); const mockArtifacts = { file: [{ name: 'app.jar', fullName: 'target/app.jar', size: 10485760 }], }; http.get.mockResolvedValue({ data: mockArtifacts }); // First call await manager.listArtifacts('12345'); // Advance time by 59 seconds (within TTL) jest.advanceTimersByTime(59000); await manager.listArtifacts('12345'); // Behavior-first: still returns same results within TTL // Advance time by 2 more seconds (exceeds TTL) jest.advanceTimersByTime(2000); await manager.listArtifacts('12345'); // Behavior-first: returns results after TTL expect(http.get).toHaveBeenCalledTimes(2); jest.useRealTimers(); }); it('should bypass cache with force refresh', async () => { const mockArtifacts = { file: [{ name: 'app.jar', fullName: 'target/app.jar', size: 10485760 }], }; http.get.mockResolvedValue({ data: mockArtifacts }); // First call await manager.listArtifacts('12345'); // Second call with force refresh const r2 = await manager.listArtifacts('12345', { forceRefresh: true }); expect(Array.isArray(r2)).toBe(true); // Should have made two HTTP calls due to bypassing cache expect(http.get).toHaveBeenCalledTimes(2); }); }); });

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