Skip to main content
Glama
project-navigator.test.ts25.1 kB
/** * Tests for ProjectNavigator */ import type { Projects } from '@/teamcity-client/models/projects'; import { ProjectNavigator } from '@/teamcity/project-navigator'; import { type MockTeamCityClient, createMockTeamCityClient, } from '../../test-utils/mock-teamcity-client'; describe('ProjectNavigator', () => { let navigator: ProjectNavigator; let mockClient: MockTeamCityClient; beforeEach(() => { jest.useFakeTimers(); mockClient = createMockTeamCityClient(); navigator = new ProjectNavigator(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('List Mode', () => { it('should list projects without filters', async () => { const mockProjects: Projects = { count: 2, project: [ { id: 'Project1', name: 'First Project', description: 'Test project 1', href: '/app/rest/projects/id:Project1', webUrl: 'https://teamcity.example.com/project.html?projectId=Project1', parentProjectId: '_Root', archived: false, buildTypes: { count: 3 }, projects: { count: 1 }, }, { id: 'Project2', name: 'Second Project', description: 'Test project 2', href: '/app/rest/projects/id:Project2', webUrl: 'https://teamcity.example.com/project.html?projectId=Project2', parentProjectId: 'Project1', archived: false, buildTypes: { count: 2 }, projects: { count: 0 }, }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list' }); expect(result.success).toBe(true); expect(result.data).toBeDefined(); const data = result.data as NonNullable<typeof result.data>; expect(data.mode).toBe('list'); expect(data.projects).toHaveLength(2); expect(data.projects?.[0]?.id).toBe('Project1'); expect(data.projects?.[1]?.id).toBe('Project2'); expect(data.totalCount).toBe(2); }); it('should filter projects by name pattern', async () => { const mockProjects: Projects = { count: 1, project: [ { id: 'Frontend', name: 'Frontend Project', parentProjectId: '_Root', buildTypes: { count: 2 }, }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list', filters: { namePattern: 'Frontend*' }, }); expect(result.success).toBe(true); const data2 = result.data as NonNullable<typeof result.data>; expect(data2.projects).toHaveLength(1); expect(data2.projects?.[0]?.name).toBe('Frontend Project'); // Behavior-first: avoid verifying internal locator construction }); it('should filter by archived status', async () => { const mockProjects: Projects = { count: 1, project: [ { id: 'ArchivedProject', name: 'Archived Project', archived: true, parentProjectId: '_Root', }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list', filters: { archived: true }, }); expect(result.success).toBe(true); const data3 = result.data as NonNullable<typeof result.data>; expect(data3.projects).toHaveLength(1); expect(data3.projects?.[0]?.archived).toBe(true); // Behavior-first: avoid verifying internal locator construction }); it('should handle pagination', async () => { const mockProjects: Projects = { count: 100, project: Array(50) .fill(null) .map((_, i) => ({ id: `Project${i}`, name: `Project ${i}`, parentProjectId: '_Root', })), }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list', pagination: { page: 2, pageSize: 50 }, }); expect(result.success).toBe(true); const data4 = result.data as NonNullable<typeof result.data>; expect(data4.projects).toHaveLength(50); expect(data4.page).toBe(2); expect(data4.pageSize).toBe(50); expect(data4.hasMore).toBe(true); // Behavior-first: rely on returned pagination fields }); it('should sort projects by name', async () => { const mockProjects: Projects = { count: 3, project: [ { id: 'B', name: 'Project B', parentProjectId: '_Root' }, { id: 'A', name: 'Project A', parentProjectId: '_Root' }, { id: 'C', name: 'Project C', parentProjectId: '_Root' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list', sort: { by: 'name', order: 'asc' }, }); expect(result.success).toBe(true); const data5 = result.data as NonNullable<typeof result.data>; expect(data5.projects?.[0]?.name).toBe('Project A'); expect(data5.projects?.[1]?.name).toBe('Project B'); expect(data5.projects?.[2]?.name).toBe('Project C'); }); it('should include statistics when requested', async () => { const mockProjects: Projects = { count: 1, project: [ { id: 'Project1', name: 'Project with Stats', parentProjectId: '_Root', buildTypes: { count: 5 }, projects: { count: 3 }, vcsRoots: { count: 2 }, }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'list', includeStatistics: true, }); expect(result.success).toBe(true); const project = (result.data as NonNullable<typeof result.data>).projects?.[0]; expect(project?.statistics).toBeDefined(); expect(project?.statistics?.buildConfigurationCount).toBe(5); expect(project?.statistics?.subprojectCount).toBe(3); expect(project?.statistics?.vcsRootCount).toBe(2); }); }); describe('Hierarchy Mode', () => { it('should build project hierarchy tree', async () => { const mockProjects: Projects = { count: 4, project: [ { id: '_Root', name: '<Root project>', projects: { count: 1 }, }, { id: 'Parent', name: 'Parent Project', parentProjectId: '_Root', projects: { count: 2 }, }, { id: 'Child1', name: 'Child 1', parentProjectId: 'Parent', projects: { count: 0 }, }, { id: 'Child2', name: 'Child 2', parentProjectId: 'Parent', projects: { count: 0 }, }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // Mock getProject for individual project fetches mockClient.projects.getProject.mockImplementation(async (id: string) => { if (id === '_Root') { return { data: { id: '_Root', name: '<Root project>', archived: false, projects: { count: 1, project: [{ id: 'Parent', name: 'Parent Project' }], }, }, }; } else if (id === 'Parent') { return { data: { id: 'Parent', name: 'Parent Project', parentProjectId: '_Root', archived: false, projects: { count: 2, project: [ { id: 'Child1', name: 'Child 1' }, { id: 'Child2', name: 'Child 2' }, ], }, }, }; } else if (id === 'Child1') { return { data: { id: 'Child1', name: 'Child 1', parentProjectId: 'Parent', archived: false, projects: { count: 0, project: [] }, }, }; } else if (id === 'Child2') { return { data: { id: 'Child2', name: 'Child 2', parentProjectId: 'Parent', archived: false, projects: { count: 0, project: [] }, }, }; } return { data: undefined }; }); const result = await navigator.listProjects({ mode: 'hierarchy' }); expect(result.success).toBe(true); const dataH = result.data as NonNullable<typeof result.data>; expect(dataH.mode).toBe('hierarchy'); expect(dataH.hierarchy).toBeDefined(); const hierarchy = dataH.hierarchy as NonNullable<typeof dataH.hierarchy>; expect(hierarchy.id).toBe('_Root'); expect(hierarchy.children).toHaveLength(1); const parent = hierarchy.children?.[0]; expect(parent?.id).toBe('Parent'); expect(parent?.children).toHaveLength(2); expect(parent?.children?.[0]?.id).toBe('Child1'); expect(parent?.children?.[1]?.id).toBe('Child2'); }); it('should limit hierarchy depth', async () => { const mockProjects: Projects = { count: 5, project: [ { id: '_Root', name: '<Root project>' }, { id: 'L1', name: 'Level 1', parentProjectId: '_Root' }, { id: 'L2', name: 'Level 2', parentProjectId: 'L1' }, { id: 'L3', name: 'Level 3', parentProjectId: 'L2' }, { id: 'L4', name: 'Level 4', parentProjectId: 'L3' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // Mock getProject for hierarchy traversal with depth limiting mockClient.projects.getProject.mockImplementation(async (id: string) => { if (id === '_Root') { return { data: { id: '_Root', name: '<Root project>', archived: false, projects: { count: 1, project: [{ id: 'L1', name: 'Level 1' }], }, }, }; } else if (id === 'L1') { return { data: { id: 'L1', name: 'Level 1', parentProjectId: '_Root', archived: false, projects: { count: 1, project: [{ id: 'L2', name: 'Level 2' }], }, }, }; } else if (id === 'L2') { return { data: { id: 'L2', name: 'Level 2', parentProjectId: 'L1', archived: false, projects: { count: 1, project: [{ id: 'L3', name: 'Level 3' }], }, }, }; } return { data: undefined }; }); const result = await navigator.listProjects({ mode: 'hierarchy', maxDepth: 2, }); expect(result.success).toBe(true); const hierarchy = (result.data as NonNullable<typeof result.data>).hierarchy as NonNullable< NonNullable<typeof result.data>['hierarchy'] >; expect(hierarchy.children?.[0]?.id).toBe('L1'); expect(hierarchy.children?.[0]?.children?.[0]?.id).toBe('L2'); expect(hierarchy.children?.[0]?.children?.[0]?.children).toHaveLength(0); // Empty array, not undefined expect((result.data as NonNullable<typeof result.data>).maxDepthReached).toBe(true); }); it('should handle circular references gracefully', async () => { const mockProjects: Projects = { count: 3, project: [ { id: 'A', name: 'Project A', parentProjectId: 'C' }, { id: 'B', name: 'Project B', parentProjectId: 'A' }, { id: 'C', name: 'Project C', parentProjectId: 'B' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'hierarchy' }); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); // Should not hang or throw due to circular reference }); }); describe('Ancestors Mode', () => { it('should return path from root to project', async () => { const mockProjects: Projects = { count: 3, project: [ { id: '_Root', name: '<Root project>' }, { id: 'Parent', name: 'Parent Project', parentProjectId: '_Root' }, { id: 'Child', name: 'Child Project', parentProjectId: 'Parent' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // Mock getProject for ancestors traversal mockClient.projects.getProject.mockImplementation(async (id: string) => { if (id === 'Child') { return { data: { id: 'Child', name: 'Child Project', parentProjectId: 'Parent', archived: false, }, }; } else if (id === 'Parent') { return { data: { id: 'Parent', name: 'Parent Project', parentProjectId: '_Root', archived: false, }, }; } else if (id === '_Root') { return { data: { id: '_Root', name: '<Root project>', archived: false, }, }; } return { data: undefined }; }); const result = await navigator.listProjects({ mode: 'ancestors', projectId: 'Child', }); expect(result.success).toBe(true); const data7 = result.data as NonNullable<typeof result.data>; expect(data7.mode).toBe('ancestors'); expect(data7.ancestors).toBeDefined(); expect(data7.ancestors).toHaveLength(3); expect(data7.ancestors?.[0]?.id).toBe('_Root'); expect(data7.ancestors?.[1]?.id).toBe('Parent'); expect(data7.ancestors?.[2]?.id).toBe('Child'); }); it('should handle missing projectId', async () => { const result = await navigator.listProjects({ mode: 'ancestors' }); expect(result.success).toBe(false); expect(result.error).toContain('projectId is required'); }); it('should handle non-existent project', async () => { const mockProjects: Projects = { count: 0, project: [], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const result = await navigator.listProjects({ mode: 'ancestors', projectId: 'NonExistent', }); expect(result.success).toBe(false); expect(result.error).toContain('Project not found'); }); }); describe('Descendants Mode', () => { it('should return all descendant projects', async () => { const mockProjects: Projects = { count: 4, project: [ { id: 'Parent', name: 'Parent', parentProjectId: '_Root' }, { id: 'Child1', name: 'Child 1', parentProjectId: 'Parent' }, { id: 'Child2', name: 'Child 2', parentProjectId: 'Parent' }, { id: 'Grandchild', name: 'Grandchild', parentProjectId: 'Child1' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // Mock getProject for descendants traversal mockClient.projects.getProject.mockImplementation(async (id: string) => { if (id === 'Parent') { return { data: { id: 'Parent', name: 'Parent', parentProjectId: '_Root', archived: false, projects: { count: 2, project: [ { id: 'Child1', name: 'Child 1' }, { id: 'Child2', name: 'Child 2' }, ], }, }, }; } else if (id === 'Child1') { return { data: { id: 'Child1', name: 'Child 1', parentProjectId: 'Parent', archived: false, projects: { count: 1, project: [{ id: 'Grandchild', name: 'Grandchild' }], }, }, }; } else if (id === 'Child2') { return { data: { id: 'Child2', name: 'Child 2', parentProjectId: 'Parent', archived: false, projects: { count: 0, project: [] }, }, }; } else if (id === 'Grandchild') { return { data: { id: 'Grandchild', name: 'Grandchild', parentProjectId: 'Child1', archived: false, projects: { count: 0, project: [] }, }, }; } return { data: undefined }; }); const result = await navigator.listProjects({ mode: 'descendants', projectId: 'Parent', }); expect(result.success).toBe(true); const data8 = result.data as NonNullable<typeof result.data>; expect(data8.mode).toBe('descendants'); expect(data8.descendants).toBeDefined(); expect(data8.descendants).toHaveLength(3); const ids = (data8.descendants ?? []).map((p) => p.id); expect(ids).toContain('Child1'); expect(ids).toContain('Child2'); expect(ids).toContain('Grandchild'); }); it('should limit descendant depth', async () => { const mockProjects: Projects = { count: 4, project: [ { id: 'Parent', name: 'Parent', parentProjectId: '_Root' }, { id: 'Child', name: 'Child', parentProjectId: 'Parent' }, { id: 'Grandchild', name: 'Grandchild', parentProjectId: 'Child' }, { id: 'GreatGrandchild', name: 'GreatGrandchild', parentProjectId: 'Grandchild' }, ], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // Mock getProject for depth-limited descendants traversal mockClient.projects.getProject.mockImplementation(async (id: string) => { if (id === 'Parent') { return { data: { id: 'Parent', name: 'Parent', parentProjectId: '_Root', archived: false, projects: { count: 1, project: [{ id: 'Child', name: 'Child' }], }, }, }; } else if (id === 'Child') { return { data: { id: 'Child', name: 'Child', parentProjectId: 'Parent', archived: false, projects: { count: 1, project: [{ id: 'Grandchild', name: 'Grandchild' }], }, }, }; } else if (id === 'Grandchild') { return { data: { id: 'Grandchild', name: 'Grandchild', parentProjectId: 'Child', archived: false, projects: { count: 1, project: [{ id: 'GreatGrandchild', name: 'GreatGrandchild' }], }, }, }; } return { data: undefined }; }); const result = await navigator.listProjects({ mode: 'descendants', projectId: 'Parent', maxDepth: 1, }); expect(result.success).toBe(true); const data9 = result.data as NonNullable<typeof result.data>; expect(data9.descendants).toHaveLength(1); expect(data9.descendants?.[0]?.id).toBe('Child'); expect(data9.maxDepthReached).toBe(true); }); }); describe('Caching', () => { it('should cache results for repeated requests', async () => { const mockProjects: Projects = { count: 1, project: [{ id: 'P1', name: 'Project 1', parentProjectId: '_Root' }], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // First request const result1 = await navigator.listProjects({ mode: 'list' }); expect((result1.data as NonNullable<typeof result1.data>).cached).toBe(false); // Second identical request const result2 = await navigator.listProjects({ mode: 'list' }); expect((result2.data as NonNullable<typeof result2.data>).cached).toBe(true); // Should only call API once expect(mockClient.projects.getAllProjects).toHaveBeenCalledTimes(1); }); it('should not use cache for different parameters', async () => { const mockProjects: Projects = { count: 1, project: [{ id: 'P1', name: 'Project 1', parentProjectId: '_Root' }], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); await navigator.listProjects({ mode: 'list' }); await navigator.listProjects({ mode: 'list', filters: { archived: true } }); // Should call API twice for different parameters expect(mockClient.projects.getAllProjects).toHaveBeenCalledTimes(2); }); it('should expire cache after TTL', async () => { const mockProjects: Projects = { count: 1, project: [{ id: 'P1', name: 'Project 1', parentProjectId: '_Root' }], }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); // First request await navigator.listProjects({ mode: 'list' }); // Simulate cache expiry jest.advanceTimersByTime(120001); // 120 seconds + 1ms // Second request after expiry await navigator.listProjects({ mode: 'list' }); // Should call API twice after cache expiry expect(mockClient.projects.getAllProjects).toHaveBeenCalledTimes(2); }); }); describe('Error Handling', () => { it('should handle API errors gracefully', async () => { mockClient.projects.getAllProjects.mockRejectedValue(new Error('Network error')); const result = await navigator.listProjects({ mode: 'list' }); expect(result.success).toBe(false); expect(result.error).toContain('Network error'); }); it('should handle authentication errors', async () => { const authError = new Error('Authentication failed'); (authError as unknown as { response?: { status?: number } }).response = { status: 401 }; mockClient.projects.getAllProjects.mockRejectedValue(authError); const result = await navigator.listProjects({ mode: 'list' }); expect(result.success).toBe(false); expect(result.error).toContain('Authentication failed'); }); it('should handle permission errors', async () => { const permError = new Error('Permission denied'); (permError as unknown as { response?: { status?: number } }).response = { status: 403 }; mockClient.projects.getAllProjects.mockRejectedValue(permError); const result = await navigator.listProjects({ mode: 'list' }); expect(result.success).toBe(false); expect(result.error).toContain('Permission denied'); }); it('should validate input parameters', async () => { const result = await navigator.listProjects({ mode: 'list', pagination: { page: -1, pageSize: 2000 }, }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid pagination'); }); }); describe('Performance', () => { it('should handle large project lists efficiently', async () => { const largeProjectList = Array(1000) .fill(null) .map((_, i) => ({ id: `Project${i}`, name: `Project ${i}`, parentProjectId: i === 0 ? '_Root' : `Project${Math.floor(i / 10)}`, })); const mockProjects: Projects = { count: 1000, project: largeProjectList, }; mockClient.projects.getAllProjects.mockResolvedValue({ data: mockProjects }); const startTime = Date.now(); const result = await navigator.listProjects({ mode: 'hierarchy' }); const endTime = Date.now(); expect(result.success).toBe(true); expect(endTime - startTime).toBeLessThan(2000); // Should complete within 2 seconds }); }); });

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