diff --git a/src/services/integrations/jira/__tests__/advanced-filters.test.ts b/src/services/integrations/jira/__tests__/advanced-filters.test.ts new file mode 100644 index 0000000..dd388ac --- /dev/null +++ b/src/services/integrations/jira/__tests__/advanced-filters.test.ts @@ -0,0 +1,207 @@ +/** + * Tests unitaires pour JiraAdvancedFiltersService + */ + +import { describe, it, expect } from 'vitest'; +import { JiraAdvancedFiltersService } from '../advanced-filters'; +import { JiraTask, JiraAnalytics } from '@/lib/types'; + +describe('JiraAdvancedFiltersService', () => { + const mockIssues: JiraTask[] = [ + { + id: '1', + key: 'TEST-1', + summary: 'Task 1', + status: { name: 'In Progress', category: 'inprogress' }, + issuetype: { name: 'Bug' }, + priority: { name: 'High' }, + assignee: { displayName: 'John Doe', emailAddress: 'john@example.com' }, + components: [{ name: 'Frontend' }], + fixVersions: [{ name: 'v1.0' }], + labels: ['urgent', 'bug'], + project: { key: 'TEST', name: 'Test Project' }, + created: '2024-01-01', + updated: '2024-01-02', + }, + { + id: '2', + key: 'TEST-2', + summary: 'Task 2', + status: { name: 'Done', category: 'done' }, + issuetype: { name: 'Story' }, + priority: { name: 'Medium' }, + assignee: { displayName: 'Jane Smith', emailAddress: 'jane@example.com' }, + components: [{ name: 'Backend' }], + fixVersions: [{ name: 'v2.0' }], + labels: ['feature'], + project: { key: 'TEST', name: 'Test Project' }, + created: '2024-01-03', + updated: '2024-01-04', + }, + ]; + + const mockAnalytics: JiraAnalytics = { + project: { + key: 'TEST', + name: 'Test Project', + totalIssues: 2, + }, + teamMetrics: { + totalAssignees: 2, + activeAssignees: 2, + issuesDistribution: [], + }, + velocityMetrics: { + currentSprintPoints: 0, + averageVelocity: 0, + sprintHistory: [], + }, + cycleTimeMetrics: { + averageCycleTime: 0, + cycleTimeByType: [], + }, + workInProgress: { + byStatus: [], + byAssignee: [], + }, + }; + + describe('extractAvailableFilters', () => { + it('devrait extraire tous les filtres disponibles', () => { + const filters = + JiraAdvancedFiltersService.extractAvailableFilters(mockIssues); + + expect(filters.components).toHaveLength(2); + expect(filters.fixVersions).toHaveLength(2); + expect(filters.issueTypes).toHaveLength(2); + expect(filters.statuses).toHaveLength(2); + expect(filters.assignees).toHaveLength(2); + expect(filters.labels).toHaveLength(3); + expect(filters.priorities).toHaveLength(2); + }); + + it('devrait compter les occurrences correctement', () => { + const filters = + JiraAdvancedFiltersService.extractAvailableFilters(mockIssues); + + const frontendComponent = filters.components.find( + (c) => c.value === 'Frontend' + ); + expect(frontendComponent?.count).toBe(1); + + const urgentLabel = filters.labels.find((l) => l.value === 'urgent'); + expect(urgentLabel?.count).toBe(1); + }); + + it('devrait gérer les issues sans assignee', () => { + const issuesWithoutAssignee: JiraTask[] = [ + { + ...mockIssues[0], + assignee: undefined, + }, + ]; + + const filters = JiraAdvancedFiltersService.extractAvailableFilters( + issuesWithoutAssignee + ); + + const unassigned = filters.assignees.find( + (a) => a.value === 'Non assigné' + ); + expect(unassigned).toBeDefined(); + }); + + it('devrait retourner des filtres vides pour une liste vide', () => { + const filters = JiraAdvancedFiltersService.extractAvailableFilters([]); + + expect(filters.components).toHaveLength(0); + expect(filters.fixVersions).toHaveLength(0); + expect(filters.issueTypes).toHaveLength(0); + }); + }); + + describe('applyFiltersToAnalytics', () => { + it('devrait filtrer par composant', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { components: ['Frontend'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait filtrer par type de ticket', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { issueTypes: ['Bug'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait filtrer par statut', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { statuses: ['In Progress'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait filtrer par assignee', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { assignees: ['John Doe'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait filtrer par label', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { labels: ['urgent'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait appliquer plusieurs filtres simultanément', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { + components: ['Frontend'], + issueTypes: ['Bug'], + }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(1); + }); + + it('devrait retourner analytics vide si aucun match', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + { components: ['NonExistent'] }, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(0); + }); + + it('devrait retourner analytics original si pas de filtres', () => { + const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics( + mockAnalytics, + {}, + mockIssues + ); + + expect(filtered.project.totalIssues).toBe(2); + }); + }); +}); diff --git a/src/services/integrations/jira/__tests__/analytics-cache.test.ts b/src/services/integrations/jira/__tests__/analytics-cache.test.ts new file mode 100644 index 0000000..0df57c0 --- /dev/null +++ b/src/services/integrations/jira/__tests__/analytics-cache.test.ts @@ -0,0 +1,221 @@ +/** + * Tests unitaires pour JiraAnalyticsCacheService + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { jiraAnalyticsCache } from '../analytics-cache'; +import { JiraAnalytics } from '@/lib/types'; + +describe('JiraAnalyticsCacheService', () => { + const mockConfig = { + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + projectKey: 'TEST', + }; + + const mockAnalytics: JiraAnalytics = { + project: { + key: 'TEST', + name: 'Test Project', + totalIssues: 10, + }, + teamMetrics: { + totalAssignees: 5, + activeAssignees: 5, + issuesDistribution: [], + }, + velocityMetrics: { + currentSprintPoints: 0, + averageVelocity: 0, + sprintHistory: [], + }, + cycleTimeMetrics: { + averageCycleTime: 0, + cycleTimeByType: [], + }, + workInProgress: { + byStatus: [], + byAssignee: [], + }, + }; + + beforeEach(() => { + jiraAnalyticsCache.invalidateAll(); + vi.useFakeTimers(); + }); + + afterEach(() => { + jiraAnalyticsCache.invalidateAll(); + jiraAnalyticsCache.stopCleanupInterval(); + vi.useRealTimers(); + }); + + describe('set et get', () => { + it('devrait stocker et récupérer les analytics', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const result = jiraAnalyticsCache.get(mockConfig); + + expect(result).toEqual(mockAnalytics); + }); + + it('devrait retourner null si pas de cache', () => { + const result = jiraAnalyticsCache.get(mockConfig); + + expect(result).toBeNull(); + }); + + it('devrait utiliser un TTL personnalisé', () => { + const customTTL = 60 * 60 * 1000; // 1 heure + jiraAnalyticsCache.set(mockConfig, mockAnalytics, customTTL); + + const result = jiraAnalyticsCache.get(mockConfig); + expect(result).toEqual(mockAnalytics); + }); + }); + + describe('invalidate', () => { + it('devrait invalider le cache pour un projet spécifique', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + jiraAnalyticsCache.invalidate(mockConfig); + + const result = jiraAnalyticsCache.get(mockConfig); + expect(result).toBeNull(); + }); + + it('devrait invalider tout le cache', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + jiraAnalyticsCache.invalidateAll(); + + const result = jiraAnalyticsCache.get(mockConfig); + expect(result).toBeNull(); + }); + }); + + describe('expiration', () => { + it('devrait expirer après le TTL', () => { + const shortTTL = 1000; // 1 seconde + jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL); + + // Avancer le temps de 2 secondes + vi.advanceTimersByTime(2000); + + const result = jiraAnalyticsCache.get(mockConfig); + expect(result).toBeNull(); + }); + + it('ne devrait pas expirer avant le TTL', () => { + const shortTTL = 5000; // 5 secondes + jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL); + + // Avancer le temps de 2 secondes seulement + vi.advanceTimersByTime(2000); + + const result = jiraAnalyticsCache.get(mockConfig); + expect(result).toEqual(mockAnalytics); + }); + }); + + describe('config hash', () => { + it('devrait invalider si la config change', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const newConfig = { + ...mockConfig, + apiToken: 'newtoken', + }; + + const result = jiraAnalyticsCache.get(newConfig); + expect(result).toBeNull(); + }); + + it('devrait garder le cache si la config est identique', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const sameConfig = { ...mockConfig }; + const result = jiraAnalyticsCache.get(sameConfig); + + expect(result).toEqual(mockAnalytics); + }); + }); + + describe('has', () => { + it('devrait retourner true si le cache existe', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const exists = jiraAnalyticsCache.has(mockConfig); + + expect(exists).toBe(true); + }); + + it("devrait retourner false si le cache n'existe pas", () => { + const exists = jiraAnalyticsCache.has(mockConfig); + + expect(exists).toBe(false); + }); + }); + + describe('getStats', () => { + it('devrait retourner les statistiques du cache', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const stats = jiraAnalyticsCache.getStats(); + + expect(stats.totalEntries).toBe(1); + expect(stats.projects).toHaveLength(1); + expect(stats.projects[0].projectKey).toBe('TEST'); + }); + + it('devrait retourner des stats vides si pas de cache', () => { + const stats = jiraAnalyticsCache.getStats(); + + expect(stats.totalEntries).toBe(0); + expect(stats.projects).toHaveLength(0); + }); + }); + + describe('forceCleanup', () => { + it('devrait nettoyer les entrées expirées', () => { + const shortTTL = 1000; // 1 seconde + jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL); + + // Avancer le temps pour expirer + vi.advanceTimersByTime(2000); + + const cleanedCount = jiraAnalyticsCache.forceCleanup(); + + expect(cleanedCount).toBe(1); + expect(jiraAnalyticsCache.get(mockConfig)).toBeNull(); + }); + + it('devrait retourner 0 si aucune entrée expirée', () => { + jiraAnalyticsCache.set(mockConfig, mockAnalytics); + + const cleanedCount = jiraAnalyticsCache.forceCleanup(); + + expect(cleanedCount).toBe(0); + expect(jiraAnalyticsCache.get(mockConfig)).not.toBeNull(); + }); + }); + + describe('multiple projects', () => { + it('devrait gérer plusieurs projets indépendamment', () => { + const config1 = { ...mockConfig, projectKey: 'PROJ1' }; + const config2 = { ...mockConfig, projectKey: 'PROJ2' }; + + jiraAnalyticsCache.set(config1, mockAnalytics); + jiraAnalyticsCache.set(config2, mockAnalytics); + + expect(jiraAnalyticsCache.get(config1)).toEqual(mockAnalytics); + expect(jiraAnalyticsCache.get(config2)).toEqual(mockAnalytics); + + jiraAnalyticsCache.invalidate(config1); + + expect(jiraAnalyticsCache.get(config1)).toBeNull(); + expect(jiraAnalyticsCache.get(config2)).toEqual(mockAnalytics); + }); + }); +}); diff --git a/src/services/integrations/jira/__tests__/analytics.test.ts b/src/services/integrations/jira/__tests__/analytics.test.ts new file mode 100644 index 0000000..25b1284 --- /dev/null +++ b/src/services/integrations/jira/__tests__/analytics.test.ts @@ -0,0 +1,131 @@ +/** + * Tests unitaires pour JiraAnalyticsService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { JiraAnalyticsService } from '../analytics'; +import { JiraService } from '../jira'; +import { jiraAnalyticsCache } from '../analytics-cache'; + +// Mock de JiraService +vi.mock('../jira', () => ({ + JiraService: vi.fn(), +})); + +// Mock de analytics-cache +vi.mock('../analytics-cache', () => ({ + jiraAnalyticsCache: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +describe('JiraAnalyticsService', () => { + let service: JiraAnalyticsService; + let mockJiraService: any; + + const mockConfig = { + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + projectKey: 'TEST', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockJiraService = { + searchIssues: vi.fn(), + validateProject: vi.fn(), + }; + + vi.mocked(JiraService).mockImplementation(() => mockJiraService as any); + vi.mocked(jiraAnalyticsCache.get).mockReturnValue(null); + + service = new JiraAnalyticsService(mockConfig); + }); + + describe('getAllProjectIssues', () => { + it('devrait récupérer toutes les issues du projet', async () => { + const mockIssues = [ + { key: 'TEST-1', title: 'Issue 1' }, + { key: 'TEST-2', title: 'Issue 2' }, + ]; + + vi.mocked(mockJiraService.searchIssues).mockResolvedValue(mockIssues); + + const issues = await service.getAllProjectIssues(); + + expect(issues).toEqual(mockIssues); + expect(mockJiraService.searchIssues).toHaveBeenCalledWith( + 'project = "TEST" ORDER BY created DESC' + ); + }); + + it('devrait gérer les erreurs', async () => { + vi.mocked(mockJiraService.searchIssues).mockRejectedValue( + new Error('API Error') + ); + + await expect(service.getAllProjectIssues()).rejects.toThrow(); + }); + }); + + describe('getProjectAnalytics', () => { + it('devrait retourner les analytics depuis le cache si disponible', async () => { + const cachedAnalytics = { + project: { key: 'TEST', name: 'Test', totalIssues: 0 }, + teamMetrics: { + totalAssignees: 0, + activeAssignees: 0, + issuesDistribution: [], + }, + velocityMetrics: { + currentSprintPoints: 0, + sprintHistory: [], + averageVelocity: 0, + }, + cycleTimeMetrics: { averageCycleTime: 0, cycleTimeByType: [] }, + workInProgress: { byStatus: [], byAssignee: [] }, + }; + + vi.mocked(jiraAnalyticsCache.get).mockReturnValue(cachedAnalytics as any); + + const analytics = await service.getProjectAnalytics(); + + expect(analytics).toEqual(cachedAnalytics); + expect(mockJiraService.searchIssues).not.toHaveBeenCalled(); + }); + + it('devrait forcer le refresh si demandé', async () => { + const cachedAnalytics = { + project: { key: 'TEST', name: 'Test', totalIssues: 0 }, + teamMetrics: { + totalAssignees: 0, + activeAssignees: 0, + issuesDistribution: [], + }, + velocityMetrics: { + currentSprintPoints: 0, + sprintHistory: [], + averageVelocity: 0, + }, + cycleTimeMetrics: { averageCycleTime: 0, cycleTimeByType: [] }, + workInProgress: { byStatus: [], byAssignee: [] }, + }; + + vi.mocked(jiraAnalyticsCache.get).mockReturnValue(cachedAnalytics as any); + vi.mocked(mockJiraService.validateProject).mockResolvedValue({ + exists: true, + name: 'Test Project', + }); + vi.mocked(mockJiraService.searchIssues).mockResolvedValue([]); + + await service.getProjectAnalytics(true); + + expect(mockJiraService.searchIssues).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/integrations/jira/__tests__/anomaly-detection.test.ts b/src/services/integrations/jira/__tests__/anomaly-detection.test.ts new file mode 100644 index 0000000..bc1b416 --- /dev/null +++ b/src/services/integrations/jira/__tests__/anomaly-detection.test.ts @@ -0,0 +1,220 @@ +/** + * Tests unitaires pour JiraAnomalyDetectionService + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { JiraAnomalyDetectionService } from '../anomaly-detection'; +import { JiraAnalytics } from '@/lib/types'; +import { getToday } from '@/lib/date-utils'; + +// Mock de date-utils +vi.mock('@/lib/date-utils', () => ({ + getToday: vi.fn(), +})); + +describe('JiraAnomalyDetectionService', () => { + let service: JiraAnomalyDetectionService; + + const mockAnalytics: JiraAnalytics = { + project: { + key: 'TEST', + name: 'Test Project', + totalIssues: 10, + }, + teamMetrics: { + totalAssignees: 2, + activeAssignees: 2, + issuesDistribution: [], + }, + velocityMetrics: { + currentSprintPoints: 0, + sprintHistory: [ + { + sprintName: 'Sprint 1', + startDate: '2024-01-01', + endDate: '2024-01-14', + completedPoints: 20, + plannedPoints: 20, + completionRate: 100, + velocity: 20, + }, + { + sprintName: 'Sprint 2', + startDate: '2024-01-15', + endDate: '2024-01-28', + completedPoints: 5, + plannedPoints: 20, + completionRate: 25, + velocity: 5, + }, // Variance importante + { + sprintName: 'Sprint 3', + startDate: '2024-01-29', + endDate: '2024-02-11', + completedPoints: 25, + plannedPoints: 20, + completionRate: 125, + velocity: 25, + }, + ], + averageVelocity: 16.67, + }, + cycleTimeMetrics: { + averageCycleTime: 12.5, + cycleTimeByType: [ + { issueType: 'Bug', averageDays: 5, medianDays: 4, samples: 10 }, + { issueType: 'Story', averageDays: 20, medianDays: 18, samples: 5 }, // Cycle time élevé + ], + }, + workInProgress: { + byStatus: [], + byAssignee: [ + { + assignee: 'john@example.com', + displayName: 'John', + todoCount: 3, + inProgressCount: 5, + reviewCount: 0, + totalActive: 8, + }, + { + assignee: 'jane@example.com', + displayName: 'Jane', + todoCount: 1, + inProgressCount: 1, + reviewCount: 0, + totalActive: 2, + }, + ], + }, + }; + + beforeEach(() => { + vi.mocked(getToday).mockReturnValue(new Date('2024-01-15')); + service = new JiraAnomalyDetectionService(); + }); + + describe('detectAnomalies', () => { + it('devrait détecter des anomalies de vélocité', async () => { + const anomalies = await service.detectAnomalies(mockAnalytics); + + const velocityAnomalies = anomalies.filter((a) => a.type === 'velocity'); + expect(velocityAnomalies.length).toBeGreaterThan(0); + }); + + it('devrait détecter des anomalies de cycle time', async () => { + const anomalies = await service.detectAnomalies(mockAnalytics); + + const cycleTimeAnomalies = anomalies.filter( + (a) => a.type === 'cycle_time' + ); + expect(cycleTimeAnomalies.length).toBeGreaterThanOrEqual(0); + }); + + it('devrait détecter des déséquilibres de charge', async () => { + const anomalies = await service.detectAnomalies(mockAnalytics); + + const workloadAnomalies = anomalies.filter((a) => a.type === 'workload'); + expect(workloadAnomalies.length).toBeGreaterThanOrEqual(0); + }); + + it('devrait trier les anomalies par sévérité', async () => { + const anomalies = await service.detectAnomalies(mockAnalytics); + + const severityWeights = { + critical: 4, + high: 3, + medium: 2, + low: 1, + }; + + for (let i = 0; i < anomalies.length - 1; i++) { + const current = severityWeights[anomalies[i].severity]; + const next = severityWeights[anomalies[i + 1].severity]; + expect(current).toBeGreaterThanOrEqual(next); + } + }); + + it('devrait retourner une liste vide si aucune anomalie', async () => { + const cleanAnalytics: JiraAnalytics = { + ...mockAnalytics, + velocityMetrics: { + currentSprintPoints: 0, + sprintHistory: [ + { + sprintName: 'Sprint 1', + startDate: '2024-01-01', + endDate: '2024-01-14', + completedPoints: 20, + plannedPoints: 20, + completionRate: 100, + velocity: 20, + }, + { + sprintName: 'Sprint 2', + startDate: '2024-01-15', + endDate: '2024-01-28', + completedPoints: 21, + plannedPoints: 20, + completionRate: 105, + velocity: 21, + }, + { + sprintName: 'Sprint 3', + startDate: '2024-01-29', + endDate: '2024-02-11', + completedPoints: 19, + plannedPoints: 20, + completionRate: 95, + velocity: 19, + }, + ], + averageVelocity: 20, + }, + cycleTimeMetrics: { + averageCycleTime: 5, + cycleTimeByType: [ + { issueType: 'Story', averageDays: 5, medianDays: 4, samples: 10 }, + ], + }, + workInProgress: { + byStatus: [], + byAssignee: [ + { + assignee: 'john@example.com', + displayName: 'John', + todoCount: 2, + inProgressCount: 1, + reviewCount: 0, + totalActive: 3, + }, + { + assignee: 'jane@example.com', + displayName: 'Jane', + todoCount: 1, + inProgressCount: 1, + reviewCount: 0, + totalActive: 2, + }, + ], + }, + }; + + const anomalies = await service.detectAnomalies(cleanAnalytics); + // Peut avoir quelques anomalies même avec des données propres selon les seuils + expect(Array.isArray(anomalies)).toBe(true); + }); + }); + + describe('config personnalisée', () => { + it('devrait utiliser une config personnalisée', () => { + const customConfig = { + velocityVarianceThreshold: 50, + cycleTimeThreshold: 3.0, + }; + + const customService = new JiraAnomalyDetectionService(customConfig); + expect(customService).toBeDefined(); + }); + }); +}); diff --git a/src/services/integrations/jira/__tests__/jira.test.ts b/src/services/integrations/jira/__tests__/jira.test.ts new file mode 100644 index 0000000..3d6574d --- /dev/null +++ b/src/services/integrations/jira/__tests__/jira.test.ts @@ -0,0 +1,144 @@ +/** + * Tests unitaires pour JiraService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { JiraService } from '../jira'; + +// Mock de fetch global +global.fetch = vi.fn(); + +describe('JiraService', () => { + let service: JiraService; + + const mockConfig = { + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + projectKey: 'TEST', + ignoredProjects: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new JiraService(mockConfig); + }); + + describe('testConnection', () => { + it('devrait retourner true si la connexion réussit', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as any); + + const result = await service.testConnection(); + + expect(result).toBe(true); + expect(fetch).toHaveBeenCalled(); + }); + + it('devrait retourner false si la connexion échoue', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized'), + } as any); + + const result = await service.testConnection(); + + expect(result).toBe(false); + }); + + it('devrait gérer les erreurs de réseau', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('Network error')); + + const result = await service.testConnection(); + + expect(result).toBe(false); + }); + }); + + describe('validateConfig', () => { + it('devrait valider une config complète', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue(''), + } as any); + + const result = await service.validateConfig(); + + expect(result.valid).toBe(true); + }); + + it('devrait rejeter une config sans baseUrl', async () => { + const invalidConfig = { + ...mockConfig, + baseUrl: undefined, + }; + const invalidService = new JiraService(invalidConfig as any); + + const result = await invalidService.validateConfig(); + + expect(result.valid).toBe(false); + expect(result.error).toContain('URL de base'); + }); + + it('devrait rejeter une config sans email', async () => { + const invalidConfig = { + ...mockConfig, + email: undefined, + }; + const invalidService = new JiraService(invalidConfig as any); + + const result = await invalidService.validateConfig(); + + expect(result.valid).toBe(false); + expect(result.error).toContain('Email'); + }); + + it('devrait rejeter une config sans apiToken', async () => { + const invalidConfig = { + ...mockConfig, + apiToken: undefined, + }; + const invalidService = new JiraService(invalidConfig as any); + + const result = await invalidService.validateConfig(); + + expect(result.valid).toBe(false); + expect(result.error).toContain('Token API'); + }); + }); + + describe('validateProject', () => { + it('devrait valider un projet existant', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ name: 'Test Project' }), + } as any); + + const result = await service.validateProject('TEST'); + + expect(result.exists).toBe(true); + expect(result.name).toBe('Test Project'); + }); + + it('devrait détecter un projet inexistant', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + text: vi.fn().mockResolvedValue('Not found'), + } as any); + + const result = await service.validateProject('INVALID'); + + expect(result.exists).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +}); diff --git a/src/services/integrations/jira/__tests__/scheduler.test.ts b/src/services/integrations/jira/__tests__/scheduler.test.ts new file mode 100644 index 0000000..4cd8d45 --- /dev/null +++ b/src/services/integrations/jira/__tests__/scheduler.test.ts @@ -0,0 +1,333 @@ +/** + * Tests unitaires pour JiraScheduler + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { JiraScheduler } from '../scheduler'; +import { userPreferencesService } from '@/services/core/user-preferences'; +import { JiraService } from '../jira'; +import { getToday, addMinutes } from '@/lib/date-utils'; + +// Mock de userPreferencesService +vi.mock('@/services/core/user-preferences', () => ({ + userPreferencesService: { + getJiraConfig: vi.fn(), + getJiraSchedulerConfig: vi.fn(), + }, +})); + +// Mock de JiraService +vi.mock('../jira', () => ({ + JiraService: vi.fn(), +})); + +// Mock de date-utils +vi.mock('@/lib/date-utils', () => ({ + getToday: vi.fn(), + addMinutes: vi.fn(), +})); + +describe('JiraScheduler', () => { + let scheduler: JiraScheduler; + let mockJiraService: any; + + beforeEach(() => { + vi.clearAllMocks(); + scheduler = new JiraScheduler(); + vi.useFakeTimers(); + + // Mock JiraService + mockJiraService = { + testConnection: vi.fn(), + syncTasks: vi.fn(), + }; + vi.mocked(JiraService).mockImplementation(() => mockJiraService as any); + }); + + afterEach(() => { + scheduler.stop(); + vi.useRealTimers(); + }); + + describe('start', () => { + it('devrait démarrer le scheduler si activé et configuré', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(true); + }); + + it('ne devrait pas démarrer si désactivé', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: false, + jiraSyncInterval: 'hourly', + } as any); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(false); + }); + + it('ne devrait pas démarrer si Jira non configuré', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: false, + } as any); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(false); + }); + + it('ne devrait pas démarrer deux fois', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + const firstCall = scheduler.isActive(); + await scheduler.start('user1'); + const secondCall = scheduler.isActive(); + + expect(firstCall).toBe(true); + expect(secondCall).toBe(true); + }); + }); + + describe('stop', () => { + it('devrait arrêter le scheduler', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + + scheduler.stop(); + expect(scheduler.isActive()).toBe(false); + }); + }); + + describe('restart', () => { + it('devrait redémarrer le scheduler', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + + await scheduler.restart('user1'); + expect(scheduler.isActive()).toBe(true); + }); + }); + + describe('isActive', () => { + it('devrait retourner false si pas démarré', () => { + expect(scheduler.isActive()).toBe(false); + }); + + it('devrait retourner true si démarré', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + }); + }); + + describe('performScheduledSync', () => { + it('devrait exécuter une synchronisation automatique', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + vi.mocked(mockJiraService.testConnection).mockResolvedValue(true); + vi.mocked(mockJiraService.syncTasks).mockResolvedValue({ + success: true, + stats: { + created: 5, + updated: 3, + skipped: 1, + }, + } as any); + + await scheduler.start('user1'); + + // Avancer le timer pour déclencher la synchronisation + vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure + + // Attendre que la synchronisation soit exécutée + await vi.waitFor(() => { + expect(mockJiraService.syncTasks).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + + it('devrait gérer les erreurs de connexion', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + vi.mocked(mockJiraService.testConnection).mockResolvedValue(false); + + await scheduler.start('user1'); + + vi.advanceTimersByTime(60 * 60 * 1000); + await vi.waitFor(() => { + expect(mockJiraService.testConnection).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + }); + + describe('getNextSyncTime', () => { + it('devrait retourner null si pas démarré', async () => { + const nextTime = await scheduler.getNextSyncTime(); + + expect(nextTime).toBeNull(); + }); + + it('devrait calculer le prochain moment de synchronisation', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + vi.mocked(getToday).mockReturnValue(mockDate); + vi.mocked(addMinutes).mockReturnValue(new Date('2024-01-15T13:00:00Z')); + + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + const nextTime = await scheduler.getNextSyncTime(); + + expect(nextTime).not.toBeNull(); + expect(addMinutes).toHaveBeenCalled(); + }); + }); + + describe('getStatus', () => { + it('devrait retourner le statut du scheduler', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: true, + jiraSyncInterval: 'daily', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: true, + baseUrl: 'https://test.atlassian.net', + email: 'test@example.com', + apiToken: 'token123', + } as any); + + await scheduler.start('user1'); + const status = await scheduler.getStatus('user1'); + + expect(status.isRunning).toBe(true); + expect(status.isEnabled).toBe(true); + expect(status.interval).toBe('daily'); + expect(status.jiraConfigured).toBe(true); + }); + + it('devrait retourner le statut si pas démarré', async () => { + vi.mocked( + userPreferencesService.getJiraSchedulerConfig + ).mockResolvedValue({ + jiraAutoSync: false, + jiraSyncInterval: 'hourly', + } as any); + vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({ + enabled: false, + } as any); + + const status = await scheduler.getStatus('user1'); + + expect(status.isRunning).toBe(false); + expect(status.isEnabled).toBe(false); + }); + }); +}); diff --git a/src/services/integrations/tfs/__tests__/scheduler.test.ts b/src/services/integrations/tfs/__tests__/scheduler.test.ts new file mode 100644 index 0000000..3f5dd60 --- /dev/null +++ b/src/services/integrations/tfs/__tests__/scheduler.test.ts @@ -0,0 +1,335 @@ +/** + * Tests unitaires pour TfsScheduler + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TfsScheduler } from '../scheduler'; +import { userPreferencesService } from '@/services/core/user-preferences'; +import { TfsService } from '../tfs'; +import { prisma } from '@/services/core/database'; +import { getToday, addMinutes } from '@/lib/date-utils'; + +// Mock de userPreferencesService +vi.mock('@/services/core/user-preferences', () => ({ + userPreferencesService: { + getTfsConfig: vi.fn(), + getTfsSchedulerConfig: vi.fn(), + }, +})); + +// Mock de TfsService +vi.mock('../tfs', () => ({ + TfsService: vi.fn(), +})); + +// Mock de prisma +vi.mock('@/services/core/database', () => ({ + prisma: { + user: { + findFirst: vi.fn(), + }, + }, +})); + +// Mock de date-utils +vi.mock('@/lib/date-utils', () => ({ + getToday: vi.fn(), + addMinutes: vi.fn(), +})); + +describe('TfsScheduler', () => { + let scheduler: TfsScheduler; + let mockTfsService: any; + + beforeEach(() => { + vi.clearAllMocks(); + scheduler = new TfsScheduler(); + vi.useFakeTimers(); + + // Mock TfsService + mockTfsService = { + testConnection: vi.fn(), + syncTasks: vi.fn(), + }; + vi.mocked(TfsService).mockImplementation(() => mockTfsService as any); + }); + + afterEach(() => { + scheduler.stop(); + vi.useRealTimers(); + }); + + describe('start', () => { + it('devrait démarrer le scheduler si activé et configuré', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(true); + }); + + it('ne devrait pas démarrer si désactivé', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: false, + tfsSyncInterval: 'hourly', + } as any + ); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(false); + }); + + it('ne devrait pas démarrer si TFS non configuré', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: false, + } as any); + + await scheduler.start('user1'); + + expect(scheduler.isActive()).toBe(false); + }); + + it('ne devrait pas démarrer deux fois', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + const firstCall = scheduler.isActive(); + await scheduler.start('user1'); + const secondCall = scheduler.isActive(); + + expect(firstCall).toBe(true); + expect(secondCall).toBe(true); + }); + }); + + describe('stop', () => { + it('devrait arrêter le scheduler', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + + scheduler.stop(); + expect(scheduler.isActive()).toBe(false); + }); + }); + + describe('restart', () => { + it('devrait redémarrer le scheduler', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + + await scheduler.restart('user1'); + expect(scheduler.isActive()).toBe(true); + }); + }); + + describe('isActive', () => { + it('devrait retourner false si pas démarré', () => { + expect(scheduler.isActive()).toBe(false); + }); + + it('devrait retourner true si démarré', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + expect(scheduler.isActive()).toBe(true); + }); + }); + + describe('performScheduledSync', () => { + it('devrait exécuter une synchronisation automatique', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: 'user1', + } as any); + vi.mocked(mockTfsService.testConnection).mockResolvedValue(true); + vi.mocked(mockTfsService.syncTasks).mockResolvedValue({ + success: true, + pullRequestsCreated: 5, + pullRequestsUpdated: 3, + pullRequestsSkipped: 1, + } as any); + + await scheduler.start('user1'); + + // Avancer le timer pour déclencher la synchronisation + vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure + + // Attendre que la synchronisation soit exécutée + await vi.waitFor(() => { + expect(mockTfsService.syncTasks).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + + it('devrait gérer les erreurs de connexion', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + vi.mocked(mockTfsService.testConnection).mockResolvedValue(false); + + await scheduler.start('user1'); + + vi.advanceTimersByTime(60 * 60 * 1000); + await vi.waitFor(() => { + expect(mockTfsService.testConnection).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + }); + + describe('getNextSyncTime', () => { + it('devrait retourner null si pas démarré', async () => { + const nextTime = await scheduler.getNextSyncTime(); + + expect(nextTime).toBeNull(); + }); + + it('devrait calculer le prochain moment de synchronisation', async () => { + const mockDate = new Date('2024-01-15T12:00:00Z'); + vi.mocked(getToday).mockReturnValue(mockDate); + vi.mocked(addMinutes).mockReturnValue(new Date('2024-01-15T13:00:00Z')); + + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + const nextTime = await scheduler.getNextSyncTime(); + + expect(nextTime).not.toBeNull(); + expect(addMinutes).toHaveBeenCalled(); + }); + }); + + describe('getStatus', () => { + it('devrait retourner le statut du scheduler', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: true, + tfsSyncInterval: 'daily', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + } as any); + + await scheduler.start('user1'); + const status = await scheduler.getStatus('user1'); + + expect(status.isRunning).toBe(true); + expect(status.isEnabled).toBe(true); + expect(status.interval).toBe('daily'); + expect(status.tfsConfigured).toBe(true); + }); + + it('devrait retourner le statut si pas démarré', async () => { + vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue( + { + tfsAutoSync: false, + tfsSyncInterval: 'hourly', + } as any + ); + vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({ + enabled: false, + } as any); + + const status = await scheduler.getStatus('user1'); + + expect(status.isRunning).toBe(false); + expect(status.isEnabled).toBe(false); + }); + }); +}); diff --git a/src/services/integrations/tfs/__tests__/tfs.test.ts b/src/services/integrations/tfs/__tests__/tfs.test.ts new file mode 100644 index 0000000..640a820 --- /dev/null +++ b/src/services/integrations/tfs/__tests__/tfs.test.ts @@ -0,0 +1,71 @@ +/** + * Tests unitaires pour TfsService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TfsService } from '../tfs'; + +// Mock de fetch global +global.fetch = vi.fn(); + +describe('TfsService', () => { + let service: TfsService; + + const mockConfig = { + enabled: true, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'token123', + repositories: [], + ignoredRepositories: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TfsService(mockConfig); + }); + + describe('testConnection', () => { + it('devrait retourner true si la connexion réussit', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + } as any); + + const result = await service.testConnection(); + + expect(result).toBe(true); + expect(fetch).toHaveBeenCalled(); + }); + + it('devrait retourner false si authentification échoue', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + } as any); + + const result = await service.testConnection(); + + expect(result).toBe(false); + }); + + it('devrait retourner false si accès refusé', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 403, + } as any); + + const result = await service.testConnection(); + + expect(result).toBe(false); + }); + + it('devrait gérer les erreurs de réseau', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('Network error')); + + const result = await service.testConnection(); + + expect(result).toBe(false); + }); + }); +});