test(JiraSync): further enhance test coverage for synchronization and change detection logic

This commit is contained in:
Julien Froidefond
2025-11-21 15:14:54 +01:00
parent 4496cd97f9
commit 411bac8162
2 changed files with 719 additions and 0 deletions

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});