diff --git a/src/services/data-management/__tests__/backup-scheduler.test.ts b/src/services/data-management/__tests__/backup-scheduler.test.ts new file mode 100644 index 0000000..b4618e7 --- /dev/null +++ b/src/services/data-management/__tests__/backup-scheduler.test.ts @@ -0,0 +1,319 @@ +/** + * Tests unitaires pour BackupScheduler + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BackupScheduler } from '../backup-scheduler'; +import { backupService } from '../backup'; +import { getToday, addMinutes } from '@/lib/date-utils'; + +// Mock de backupService +vi.mock('../backup', () => ({ + backupService: { + getConfigSync: vi.fn(), + createBackup: vi.fn(), + }, +})); + +// Mock de date-utils +vi.mock('@/lib/date-utils', () => ({ + getToday: vi.fn(), + addMinutes: vi.fn(), +})); + +describe('BackupScheduler', () => { + let scheduler: BackupScheduler; + + beforeEach(() => { + vi.clearAllMocks(); + scheduler = new BackupScheduler(); + vi.useFakeTimers(); + }); + + afterEach(() => { + scheduler.stop(); + vi.useRealTimers(); + }); + + describe('start', () => { + it('devrait démarrer le scheduler si activé', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + + expect(scheduler.isActive()).toBe(true); + }); + + it('ne devrait pas démarrer si désactivé', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: false, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + + expect(scheduler.isActive()).toBe(false); + }); + + it('ne devrait pas démarrer deux fois', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + const firstCall = scheduler.isActive(); + scheduler.start(); + const secondCall = scheduler.isActive(); + + expect(firstCall).toBe(true); + expect(secondCall).toBe(true); + }); + }); + + describe('stop', () => { + it('devrait arrêter le scheduler', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + expect(scheduler.isActive()).toBe(true); + + scheduler.stop(); + expect(scheduler.isActive()).toBe(false); + }); + + it("devrait gérer l'arrêt si pas démarré", () => { + scheduler.stop(); + expect(scheduler.isActive()).toBe(false); + }); + }); + + describe('restart', () => { + it('devrait redémarrer le scheduler', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + expect(scheduler.isActive()).toBe(true); + + scheduler.restart(); + 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é', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + expect(scheduler.isActive()).toBe(true); + }); + }); + + describe('performScheduledBackup', () => { + it('devrait exécuter une sauvegarde automatique', async () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + vi.mocked(backupService.createBackup).mockResolvedValue({ + id: '1', + filename: 'backup.db.gz', + size: 1024, + createdAt: new Date(), + type: 'automatic', + status: 'success', + } as any); + + scheduler.start(); + + // Avancer le timer pour déclencher la sauvegarde une seule fois + vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure + + // Attendre que la sauvegarde soit exécutée + await vi.waitFor(() => { + expect(backupService.createBackup).toHaveBeenCalledWith('automatic'); + }); + + scheduler.stop(); + }); + + it('devrait gérer les sauvegardes ignorées', async () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + vi.mocked(backupService.createBackup).mockResolvedValue(null); + + scheduler.start(); + + vi.advanceTimersByTime(60 * 60 * 1000); + await vi.waitFor(() => { + expect(backupService.createBackup).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + + it('devrait gérer les erreurs de sauvegarde', async () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + vi.mocked(backupService.createBackup).mockRejectedValue( + new Error('Backup failed') + ); + + scheduler.start(); + + vi.advanceTimersByTime(60 * 60 * 1000); + await vi.waitFor(() => { + expect(backupService.createBackup).toHaveBeenCalled(); + }); + + scheduler.stop(); + }); + }); + + describe('getIntervalMs', () => { + it('devrait convertir hourly en millisecondes', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + const status = scheduler.getStatus(); + + expect(status.interval).toBe('hourly'); + }); + + it('devrait convertir daily en millisecondes', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'daily', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + const status = scheduler.getStatus(); + + expect(status.interval).toBe('daily'); + }); + + it('devrait convertir weekly en millisecondes', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'weekly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + const status = scheduler.getStatus(); + + expect(status.interval).toBe('weekly'); + }); + }); + + describe('getNextBackupTime', () => { + it('devrait retourner null si pas démarré', () => { + const nextTime = scheduler.getNextBackupTime(); + + expect(nextTime).toBeNull(); + }); + + it('devrait calculer le prochain moment de sauvegarde', () => { + 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(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + scheduler.start(); + const nextTime = scheduler.getNextBackupTime(); + + expect(nextTime).not.toBeNull(); + expect(addMinutes).toHaveBeenCalled(); + }); + }); + + describe('getStatus', () => { + it('devrait retourner le statut du scheduler', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: true, + interval: 'daily', + maxBackups: 10, + backupPath: '/custom/backups', + } as any); + + scheduler.start(); + const status = scheduler.getStatus(); + + expect(status.isRunning).toBe(true); + expect(status.isEnabled).toBe(true); + expect(status.interval).toBe('daily'); + expect(status.maxBackups).toBe(10); + expect(status.backupPath).toBe('/custom/backups'); + }); + + it('devrait retourner le statut si pas démarré', () => { + vi.mocked(backupService.getConfigSync).mockReturnValue({ + enabled: false, + interval: 'hourly', + maxBackups: 5, + backupPath: '/backups', + } as any); + + const status = scheduler.getStatus(); + + expect(status.isRunning).toBe(false); + expect(status.isEnabled).toBe(false); + }); + }); +}); diff --git a/src/services/data-management/__tests__/backup.test.ts b/src/services/data-management/__tests__/backup.test.ts new file mode 100644 index 0000000..741cde8 --- /dev/null +++ b/src/services/data-management/__tests__/backup.test.ts @@ -0,0 +1,400 @@ +/** + * Tests unitaires pour BackupService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BackupService } from '../backup'; +import { prisma } from '@/services/core/database'; +import { userPreferencesService } from '@/services/core/user-preferences'; +import { BackupUtils } from '@/lib/backup-utils'; +import { promises as fs } from 'fs'; +import path from 'path'; + +// Mock de prisma +vi.mock('@/services/core/database', () => ({ + prisma: { + $disconnect: vi.fn(), + $connect: vi.fn(), + $queryRaw: vi.fn(), + userPreferences: { + upsert: vi.fn(), + }, + }, +})); + +// Mock de userPreferencesService +vi.mock('@/services/core/user-preferences', () => ({ + userPreferencesService: { + getAllPreferences: vi.fn(), + getViewPreferences: vi.fn(), + }, +})); + +// Mock de BackupUtils +vi.mock('@/lib/backup-utils', () => ({ + BackupUtils: { + resolveBackupStoragePath: vi.fn(), + resolveDatabasePath: vi.fn(), + calculateFileHash: vi.fn(), + ensureDirectory: vi.fn(), + createSQLiteBackup: vi.fn(), + compressFile: vi.fn(), + decompressFileTemp: vi.fn(), + writeLogEntry: vi.fn(), + parseBackupFilename: vi.fn(), + generateBackupFilename: vi.fn(), + }, +})); + +// Mock de fs (pour promises) +vi.mock('fs', () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + readdir: vi.fn(), + unlink: vi.fn(), + access: vi.fn(), + copyFile: vi.fn(), + }, +})); + +describe('BackupService', () => { + let backupService: BackupService; + + beforeEach(() => { + vi.clearAllMocks(); + backupService = new BackupService(); + + // Mocks par défaut + vi.mocked(BackupUtils.resolveBackupStoragePath).mockReturnValue('/backups'); + vi.mocked(BackupUtils.resolveDatabasePath).mockReturnValue('/db/dev.db'); + vi.mocked(BackupUtils.calculateFileHash).mockResolvedValue('hash123'); + vi.mocked(BackupUtils.ensureDirectory).mockResolvedValue(undefined); + vi.mocked(BackupUtils.createSQLiteBackup).mockResolvedValue(undefined); + vi.mocked(BackupUtils.compressFile).mockResolvedValue( + '/backups/file.db.gz' + ); + vi.mocked(BackupUtils.parseBackupFilename).mockReturnValue({ + type: 'manual', + date: new Date(), + }); + vi.mocked(BackupUtils.generateBackupFilename).mockReturnValue( + 'towercontrol_manual_2024-01-01.db' + ); + vi.mocked(fs.stat).mockResolvedValue({ + size: 1024, + birthtime: new Date(), + } as any); + vi.mocked(fs.readdir).mockResolvedValue([]); + vi.mocked(userPreferencesService.getAllPreferences).mockResolvedValue({ + viewPreferences: {}, + } as any); + vi.mocked(userPreferencesService.getViewPreferences).mockResolvedValue( + {} as any + ); + }); + + describe('createBackup', () => { + it('devrait créer une sauvegarde manuelle', async () => { + vi.mocked(BackupUtils.compressFile).mockResolvedValue( + '/backups/towercontrol_manual_2024-01-01.db.gz' + ); + vi.mocked(fs.stat).mockResolvedValue({ + size: 2048, + birthtime: new Date(), + } as any); + + const result = await backupService.createBackup('manual', true); + + expect(result).not.toBeNull(); + expect(result?.type).toBe('manual'); + expect(result?.status).toBe('success'); + expect(result?.filename).toContain('towercontrol_manual'); + }); + + it('devrait créer une sauvegarde automatique', async () => { + vi.mocked(BackupUtils.compressFile).mockResolvedValue( + '/backups/towercontrol_automatic_2024-01-01.db.gz' + ); + + const result = await backupService.createBackup('automatic', true); + + expect(result).not.toBeNull(); + expect(result?.type).toBe('automatic'); + }); + + it('devrait retourner null si pas de changements et forceCreate=false', async () => { + // Mock pour simuler qu'il n'y a pas de changements + vi.mocked(fs.readdir).mockResolvedValue([ + 'towercontrol_manual_2024-01-01.db.gz', + ] as any); + vi.mocked(BackupUtils.calculateFileHash).mockResolvedValue('samehash'); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ databaseHash: 'samehash' }) + ); + + const result = await backupService.createBackup('automatic', false); + + // Si pas de changements, devrait retourner null + expect(result).toBeNull(); + }); + + it('devrait gérer les erreurs lors de la création', async () => { + vi.mocked(BackupUtils.createSQLiteBackup).mockRejectedValue( + new Error('Backup failed') + ); + + const result = await backupService.createBackup('manual', true); + + expect(result).not.toBeNull(); + expect(result?.status).toBe('failed'); + expect(result?.error).toBeDefined(); + }); + }); + + describe('listBackups', () => { + it('devrait lister les sauvegardes disponibles', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + 'towercontrol_manual_2024-01-01.db.gz', + 'towercontrol_automatic_2024-01-02.db.gz', + ] as any); + + const backups = await backupService.listBackups(); + + expect(backups).toHaveLength(2); + expect(backups[0].filename).toContain('towercontrol_'); + }); + + it('devrait retourner une liste vide si aucun backup', async () => { + vi.mocked(fs.readdir).mockResolvedValue([]); + + const backups = await backupService.listBackups(); + + expect(backups).toHaveLength(0); + }); + + it('devrait filtrer les fichiers non-backup', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + 'towercontrol_manual_2024-01-01.db.gz', + 'other_file.txt', + 'backup.log', + ] as any); + + const backups = await backupService.listBackups(); + + expect(backups).toHaveLength(1); + }); + + it('devrait gérer les erreurs', async () => { + (fs.readdir as any).mockRejectedValue(new Error('Read error')); + + const backups = await backupService.listBackups(); + + expect(backups).toHaveLength(0); + }); + }); + + describe('deleteBackup', () => { + it('devrait supprimer un backup et ses métadonnées', async () => { + await backupService.deleteBackup('backup.db.gz'); + + expect(fs.unlink).toHaveBeenCalledWith( + path.join('/backups', 'backup.db.gz') + ); + expect(fs.unlink).toHaveBeenCalledWith( + path.join('/backups', 'backup.db.gz.meta.json') + ); + }); + + it('devrait gérer les erreurs lors de la suppression', async () => { + vi.mocked(fs.unlink).mockRejectedValue(new Error('Delete failed')); + + await expect( + backupService.deleteBackup('backup.db.gz') + ).rejects.toThrow(); + }); + }); + + describe('restoreBackup', () => { + beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { integrity_check: 'ok' }, + ]); + vi.spyOn(backupService, 'createBackup').mockResolvedValue({ + id: 'pre-restore', + filename: 'pre-restore.db.gz', + size: 1024, + createdAt: new Date(), + type: 'manual', + status: 'success', + } as any); + }); + + it('devrait restaurer un backup non compressé', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + + await backupService.restoreBackup('backup.db'); + + expect(fs.copyFile).toHaveBeenCalled(); + expect(prisma.$connect).toHaveBeenCalled(); + }); + + it('devrait décompresser et restaurer un backup compressé', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(BackupUtils.decompressFileTemp).mockResolvedValue(undefined); + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await backupService.restoreBackup('backup.db.gz'); + + expect(BackupUtils.decompressFileTemp).toHaveBeenCalled(); + expect(fs.copyFile).toHaveBeenCalled(); + }); + + it('devrait gérer les erreurs lors de la restauration', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('File not found')); + + await expect(backupService.restoreBackup('backup.db')).rejects.toThrow(); + }); + }); + + describe('verifyDatabaseHealth', () => { + it("devrait vérifier l'intégrité de la base de données", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { integrity_check: 'ok' }, + ]); + + await backupService.verifyDatabaseHealth(); + + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); + + it("devrait échouer si l'intégrité est compromise", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { integrity_check: 'error in database' }, + ]); + + await expect(backupService.verifyDatabaseHealth()).rejects.toThrow(); + }); + }); + + describe('hasChangedSinceLastBackup', () => { + it('devrait retourner true si aucun backup précédent', async () => { + vi.mocked(fs.readdir).mockResolvedValue([]); + + const hasChanged = await backupService.hasChangedSinceLastBackup(); + + expect(hasChanged).toBe(true); + }); + + it('devrait retourner true si le hash a changé', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + 'towercontrol_manual_2024-01-01.db.gz', + ] as any); + vi.mocked(BackupUtils.calculateFileHash) + .mockResolvedValueOnce('newhash') + .mockResolvedValueOnce('oldhash'); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ databaseHash: 'oldhash' }) + ); + + const hasChanged = await backupService.hasChangedSinceLastBackup(); + + expect(hasChanged).toBe(true); + }); + }); + + describe('getBackupLogs', () => { + it('devrait lire les logs de backup', async () => { + vi.mocked(fs.readFile).mockResolvedValue( + '2024-01-01: Backup created\n2024-01-02: Backup created' + ); + + const logs = await backupService.getBackupLogs(); + + expect(logs).toHaveLength(2); + }); + + it("devrait retourner une liste vide si le fichier n'existe pas", async () => { + (fs.readFile as any).mockRejectedValue(new Error('File not found')); + + const logs = await backupService.getBackupLogs(); + + expect(logs).toHaveLength(0); + }); + + it('devrait limiter le nombre de lignes', async () => { + const manyLines = Array.from({ length: 200 }, (_, i) => `Line ${i}`).join( + '\n' + ); + vi.mocked(fs.readFile).mockResolvedValue(manyLines); + + const logs = await backupService.getBackupLogs(50); + + expect(logs.length).toBeLessThanOrEqual(50); + }); + }); + + describe('getBackupStats', () => { + it('devrait calculer les statistiques par jour', async () => { + const now = new Date('2024-01-15'); + vi.useFakeTimers(); + vi.setSystemTime(now); + + vi.mocked(fs.readdir).mockResolvedValue([ + 'towercontrol_manual_2024-01-15.db.gz', + 'towercontrol_automatic_2024-01-15.db.gz', + 'towercontrol_manual_2024-01-14.db.gz', + ] as any); + vi.mocked(BackupUtils.parseBackupFilename) + .mockReturnValueOnce({ + type: 'manual', + date: new Date('2024-01-15'), + }) + .mockReturnValueOnce({ + type: 'automatic', + date: new Date('2024-01-15'), + }) + .mockReturnValueOnce({ + type: 'manual', + date: new Date('2024-01-14'), + }); + + const stats = await backupService.getBackupStats(7); + + expect(stats.length).toBeGreaterThan(0); + const todayStats = stats.find((s) => s.date === '2024-01-15'); + expect(todayStats).toBeDefined(); + expect(todayStats?.manual).toBe(1); + expect(todayStats?.automatic).toBe(1); + + vi.useRealTimers(); + }); + }); + + describe('getConfig', () => { + it('devrait retourner la configuration', async () => { + const config = await backupService.getConfig(); + + expect(config).toHaveProperty('enabled'); + expect(config).toHaveProperty('interval'); + expect(config).toHaveProperty('maxBackups'); + expect(config).toHaveProperty('backupPath'); + }); + }); + + describe('updateConfig', () => { + it('devrait mettre à jour la configuration', async () => { + vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({ + userId: 'default', + } as any); + + await backupService.updateConfig({ maxBackups: 10 }); + + expect(prisma.userPreferences.upsert).toHaveBeenCalled(); + }); + }); +});