feat: automatische geplante Backups mit Rotation

Phase 1.3 - Automatische Backups:
- Cron-basierter Scheduler (Standard: täglich 2 Uhr)
- Konfigurierbar über .env (Zeitplan, Verzeichnis, Anzahl)
- Automatische Rotation: behält nur letzte N Backups (Standard: 7)
- UI in Settings → Backup: Status-Anzeige und manueller Trigger
- Tests: 7 erfolgreiche Tests für Scheduler-Funktionalität

Neue Umgebungsvariablen:
- BACKUP_ENABLED (Standard: true)
- BACKUP_SCHEDULE (Standard: 0 2 * * *)
- BACKUP_DIR (Standard: ./backups)
- BACKUP_KEEP (Standard: 7)
- TZ (für Zeitzone)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 07:02:38 +02:00
parent 99a2280c02
commit 9b29d1847c
11 changed files with 484 additions and 3 deletions
+94
View File
@@ -0,0 +1,94 @@
/**
* Test: Backup Scheduler
* Purpose: Verify automated backup scheduling functionality
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs/promises';
import path from 'node:path';
const TEST_BACKUP_DIR = './test-backups';
// Mock environment variables
process.env.BACKUP_ENABLED = 'false'; // Disable scheduler for tests
process.env.BACKUP_DIR = TEST_BACKUP_DIR;
process.env.BACKUP_KEEP = '3';
describe('Backup Scheduler', () => {
let backupScheduler;
it('should load the backup scheduler module', async () => {
backupScheduler = await import('./server/services/backup-scheduler.js');
assert.ok(backupScheduler.getStatus, 'getStatus function should exist');
assert.ok(backupScheduler.triggerBackup, 'triggerBackup function should exist');
});
it('should report correct status when disabled', () => {
const status = backupScheduler.getStatus();
assert.strictEqual(status.enabled, false, 'Scheduler should be disabled');
assert.strictEqual(status.schedule, '0 2 * * *', 'Default schedule should be set');
assert.strictEqual(status.backupDir, TEST_BACKUP_DIR, 'Backup directory should match');
assert.strictEqual(status.keepCount, 3, 'Keep count should be 3');
assert.strictEqual(status.running, false, 'Scheduler should not be running');
});
it('should create backup directory if it does not exist', async () => {
// Clean up any existing test directory
try {
await fs.rm(TEST_BACKUP_DIR, { recursive: true, force: true });
} catch {}
// Trigger a backup
const result = await backupScheduler.triggerBackup();
assert.ok(result, 'Trigger should return result');
assert.ok(result.timestamp, 'Result should have timestamp');
// Check if directory was created
const dirExists = await fs.access(TEST_BACKUP_DIR).then(() => true).catch(() => false);
assert.ok(dirExists, 'Backup directory should be created');
});
it('should create a backup file with timestamp', async () => {
const beforeFiles = await fs.readdir(TEST_BACKUP_DIR).catch(() => []);
await backupScheduler.triggerBackup();
const afterFiles = await fs.readdir(TEST_BACKUP_DIR);
const newFiles = afterFiles.filter(f => !beforeFiles.includes(f));
assert.strictEqual(newFiles.length, 1, 'Should create exactly one new backup file');
assert.ok(newFiles[0].startsWith('oikos-backup-'), 'Backup file should have correct prefix');
assert.ok(newFiles[0].endsWith('.db'), 'Backup file should have .db extension');
});
it('should rotate old backups (keep only last N)', async () => {
// Create 5 backups (more than BACKUP_KEEP=3)
for (let i = 0; i < 5; i++) {
await backupScheduler.triggerBackup();
// Small delay to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 100));
}
const files = await fs.readdir(TEST_BACKUP_DIR);
const backupFiles = files.filter(f => f.startsWith('oikos-backup-') && f.endsWith('.db'));
assert.strictEqual(backupFiles.length, 3, 'Should keep only last 3 backups');
});
it('should update lastBackup status after trigger', async () => {
await backupScheduler.triggerBackup();
const status = backupScheduler.getStatus();
assert.ok(status.lastBackup, 'Should have lastBackup info');
assert.ok(status.lastBackup.timestamp, 'Last backup should have timestamp');
assert.strictEqual(status.lastBackup.success, true, 'Last backup should be successful');
assert.ok(status.lastBackup.file, 'Last backup should have filename');
});
it('should cleanup test directory', async () => {
await fs.rm(TEST_BACKUP_DIR, { recursive: true, force: true });
});
});