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:
@@ -33,6 +33,13 @@ APPLE_APP_SPECIFIC_PASSWORD=
|
||||
# Calendar sync interval in minutes (default: 15)
|
||||
SYNC_INTERVAL_MINUTES=15
|
||||
|
||||
# Automatic Backups
|
||||
# BACKUP_ENABLED=true # Enable/disable automated backups (default: true)
|
||||
# BACKUP_SCHEDULE=0 2 * * * # Cron schedule (default: 2 AM daily)
|
||||
# BACKUP_DIR=./backups # Backup directory (default: ./backups)
|
||||
# BACKUP_KEEP=7 # Number of backups to keep (default: 7)
|
||||
# TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC)
|
||||
|
||||
# Security
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_ATTEMPTS=5
|
||||
|
||||
Generated
+10
@@ -16,6 +16,7 @@
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.1.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1655,6 +1656,15 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -26,7 +26,8 @@
|
||||
"test:setup": "node test-setup.js",
|
||||
"test:ics-parser": "node test-ics-parser.js",
|
||||
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs"
|
||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -36,6 +37,7 @@
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.1.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
+16
-1
@@ -973,7 +973,22 @@
|
||||
"backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...",
|
||||
"backupCliTitle": "CLI / Docker-Compose-Wiederherstellung",
|
||||
"backupCliHint": "Für operative Wiederherstellungen die App stoppen, das Backup in einen temporären Container einbinden und die Datenbankdatei ersetzen.",
|
||||
"backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:"
|
||||
"backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:",
|
||||
"backupSchedulerTitle": "Automatische Backups",
|
||||
"backupSchedulerHint": "Geplante Backups werden automatisch erstellt und alte Backups rotiert.",
|
||||
"backupSchedulerStatus": "Status",
|
||||
"backupSchedulerEnabled": "Aktiv",
|
||||
"backupSchedulerDisabled": "Deaktiviert",
|
||||
"backupSchedulerSchedule": "Zeitplan",
|
||||
"backupSchedulerKeep": "Aufbewahrung",
|
||||
"backupSchedulerKeepCount": "{{count}} Backups",
|
||||
"backupSchedulerLastBackup": "Letztes Backup",
|
||||
"backupSchedulerLastSuccess": "{{date}} (erfolgreich)",
|
||||
"backupSchedulerLastFail": "{{date}} (fehlgeschlagen)",
|
||||
"backupSchedulerNever": "Noch kein Backup erstellt",
|
||||
"backupSchedulerTrigger": "Jetzt Backup erstellen",
|
||||
"backupSchedulerTriggering": "Backup wird erstellt...",
|
||||
"backupSchedulerTriggeredToast": "Backup erfolgreich erstellt."
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
||||
|
||||
+16
-1
@@ -967,7 +967,22 @@
|
||||
"backupRestoredToast": "Database restored. Reloading...",
|
||||
"backupCliTitle": "CLI / Docker Compose restore",
|
||||
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
|
||||
"backupCliBackupHint": "You can also create a backup directly from Docker Compose:"
|
||||
"backupCliBackupHint": "You can also create a backup directly from Docker Compose:",
|
||||
"backupSchedulerTitle": "Automatic Backups",
|
||||
"backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.",
|
||||
"backupSchedulerStatus": "Status",
|
||||
"backupSchedulerEnabled": "Enabled",
|
||||
"backupSchedulerDisabled": "Disabled",
|
||||
"backupSchedulerSchedule": "Schedule",
|
||||
"backupSchedulerKeep": "Retention",
|
||||
"backupSchedulerKeepCount": "{{count}} backups",
|
||||
"backupSchedulerLastBackup": "Last backup",
|
||||
"backupSchedulerLastSuccess": "{{date}} (successful)",
|
||||
"backupSchedulerLastFail": "{{date}} (failed)",
|
||||
"backupSchedulerNever": "No backup created yet",
|
||||
"backupSchedulerTrigger": "Create backup now",
|
||||
"backupSchedulerTriggering": "Creating backup...",
|
||||
"backupSchedulerTriggeredToast": "Backup created successfully."
|
||||
},
|
||||
"login": {
|
||||
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
||||
|
||||
@@ -798,6 +798,14 @@ export async function render(container, { user }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="backup-scheduler-card">
|
||||
<h3 class="settings-card__title">${t('settings.backupSchedulerTitle')}</h3>
|
||||
<p class="form-hint">${t('settings.backupSchedulerHint')}</p>
|
||||
<div class="settings-info-grid" id="backup-scheduler-info">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.backupCliTitle')}</h3>
|
||||
<p class="form-hint">${t('settings.backupCliHint')}</p>
|
||||
@@ -1535,7 +1543,83 @@ function bindApiTokenEvents(container, initialTokens) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBackupSchedulerStatus(container) {
|
||||
const infoContainer = container.querySelector('#backup-scheduler-info');
|
||||
if (!infoContainer) return;
|
||||
|
||||
try {
|
||||
const res = await api.get('/backup/status');
|
||||
const scheduler = res.data?.scheduler;
|
||||
if (!scheduler) return;
|
||||
|
||||
const { enabled, schedule, keepCount, lastBackup } = scheduler;
|
||||
|
||||
let lastBackupText = t('settings.backupSchedulerNever');
|
||||
if (lastBackup?.timestamp) {
|
||||
const date = formatDate(lastBackup.timestamp) + ' ' + formatTime(lastBackup.timestamp);
|
||||
lastBackupText = lastBackup.success
|
||||
? t('settings.backupSchedulerLastSuccess', { date })
|
||||
: t('settings.backupSchedulerLastFail', { date });
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-label">${t('settings.backupSchedulerStatus')}</span>
|
||||
<span class="settings-info-value ${enabled ? 'settings-info-value--success' : ''}">
|
||||
${enabled ? t('settings.backupSchedulerEnabled') : t('settings.backupSchedulerDisabled')}
|
||||
</span>
|
||||
</div>
|
||||
${enabled ? `
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-label">${t('settings.backupSchedulerSchedule')}</span>
|
||||
<span class="settings-info-value"><code>${esc(schedule)}</code></span>
|
||||
</div>
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-label">${t('settings.backupSchedulerKeep')}</span>
|
||||
<span class="settings-info-value">${t('settings.backupSchedulerKeepCount', { count: keepCount })}</span>
|
||||
</div>
|
||||
<div class="settings-info-row">
|
||||
<span class="settings-info-label">${t('settings.backupSchedulerLastBackup')}</span>
|
||||
<span class="settings-info-value">${esc(lastBackupText)}</span>
|
||||
</div>
|
||||
<div class="settings-form-actions">
|
||||
<button class="btn btn--secondary" id="backup-trigger-btn">${t('settings.backupSchedulerTrigger')}</button>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
infoContainer.replaceChildren();
|
||||
infoContainer.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
// Event-Handler für manuellen Trigger
|
||||
const triggerBtn = infoContainer.querySelector('#backup-trigger-btn');
|
||||
if (triggerBtn) {
|
||||
triggerBtn.addEventListener('click', async () => {
|
||||
triggerBtn.disabled = true;
|
||||
triggerBtn.textContent = t('settings.backupSchedulerTriggering');
|
||||
try {
|
||||
await api.post('/backup/trigger');
|
||||
window.oikos?.showToast(t('settings.backupSchedulerTriggeredToast'), 'success');
|
||||
// Status neu laden
|
||||
loadBackupSchedulerStatus(container);
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
|
||||
triggerBtn.disabled = false;
|
||||
triggerBtn.textContent = t('settings.backupSchedulerTrigger');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load backup scheduler status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function bindBackupEvents(container) {
|
||||
// Scheduler-Status laden und anzeigen
|
||||
loadBackupSchedulerStatus(container);
|
||||
|
||||
const form = container.querySelector('#backup-restore-form');
|
||||
const fileInput = container.querySelector('#backup-restore-file');
|
||||
const selectedFile = container.querySelector('#backup-selected-file');
|
||||
|
||||
@@ -724,3 +724,51 @@
|
||||
.cat-add-form .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Backup Scheduler Info Grid
|
||||
-------------------------------------------------------- */
|
||||
|
||||
.settings-info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.settings-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-info-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.settings-info-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-info-value--success {
|
||||
color: var(--color-success);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.settings-info-value code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { buildOpenApiSpec } from './openapi.js';
|
||||
import * as googleCalendar from './services/google-calendar.js';
|
||||
import * as appleCalendar from './services/apple-calendar.js';
|
||||
import * as icsSubscription from './services/ics-subscription.js';
|
||||
import { startScheduler as startBackupScheduler } from './services/backup-scheduler.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import tasksRouter from './routes/tasks.js';
|
||||
import shoppingRouter from './routes/shopping.js';
|
||||
@@ -274,6 +275,9 @@ app.listen(PORT, () => {
|
||||
setInterval(runSync, SYNC_INTERVAL_MS);
|
||||
logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`);
|
||||
}, 10_000);
|
||||
|
||||
// Backup-Scheduler starten
|
||||
startBackupScheduler();
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -11,6 +11,7 @@ import fs from 'node:fs/promises';
|
||||
import { backupToFile, currentVersion, restoreFromFile } from '../db.js';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { getStatus as getSchedulerStatus, triggerBackup } from '../services/backup-scheduler.js';
|
||||
|
||||
const router = express.Router();
|
||||
const log = createLogger('Backup');
|
||||
@@ -22,10 +23,12 @@ function backupFileName() {
|
||||
}
|
||||
|
||||
router.get('/status', requireAdmin, (req, res) => {
|
||||
const schedulerStatus = getSchedulerStatus();
|
||||
res.json({
|
||||
data: {
|
||||
schema_version: currentVersion(),
|
||||
restore_upload_limit: RESTORE_LIMIT,
|
||||
scheduler: schedulerStatus,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -87,6 +90,16 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/trigger', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await triggerBackup();
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
log.error('Manual backup trigger failed:', err);
|
||||
res.status(500).json({ error: 'Backup trigger failed.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.use((err, req, res, next) => {
|
||||
if (err?.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: `Backup file is too large. Maximum upload size is ${RESTORE_LIMIT}.`, code: 413 });
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Module: Backup Scheduler
|
||||
* Purpose: Automated scheduled database backups with rotation
|
||||
* Dependencies: node-cron, fs/promises, path, server/db.js
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { backupToFile } from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const log = createLogger('BackupScheduler');
|
||||
|
||||
// Configuration from environment variables
|
||||
const BACKUP_SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'; // Default: 2 AM daily
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
||||
const BACKUP_KEEP = parseInt(process.env.BACKUP_KEEP || '7', 10); // Default: keep last 7 backups
|
||||
const BACKUP_ENABLED = process.env.BACKUP_ENABLED !== 'false'; // Default: enabled
|
||||
|
||||
let scheduledTask = null;
|
||||
let lastBackup = null;
|
||||
let lastError = null;
|
||||
|
||||
/**
|
||||
* Generate timestamped backup filename
|
||||
*/
|
||||
function backupFileName() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `oikos-backup-${stamp}.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure backup directory exists
|
||||
*/
|
||||
async function ensureBackupDir() {
|
||||
try {
|
||||
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
||||
} catch (err) {
|
||||
log.error('Failed to create backup directory:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backup files sorted by modification time (newest first)
|
||||
*/
|
||||
async function getBackupFiles() {
|
||||
try {
|
||||
const files = await fs.readdir(BACKUP_DIR);
|
||||
const backupFiles = files.filter((f) => f.startsWith('oikos-backup-') && f.endsWith('.db'));
|
||||
|
||||
// Get file stats and sort by modification time
|
||||
const filesWithStats = await Promise.all(
|
||||
backupFiles.map(async (file) => {
|
||||
const filePath = path.join(BACKUP_DIR, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
return { file, mtime: stats.mtime, path: filePath };
|
||||
})
|
||||
);
|
||||
|
||||
return filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate backups - keep only the last N backups
|
||||
*/
|
||||
async function rotateBackups() {
|
||||
try {
|
||||
const files = await getBackupFiles();
|
||||
|
||||
if (files.length <= BACKUP_KEEP) {
|
||||
return; // Nothing to delete
|
||||
}
|
||||
|
||||
const filesToDelete = files.slice(BACKUP_KEEP);
|
||||
|
||||
for (const { file, path: filePath } of filesToDelete) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
log.info(`Rotated old backup: ${file}`);
|
||||
} catch (err) {
|
||||
log.error(`Failed to delete old backup ${file}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Backup rotation failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform automated backup
|
||||
*/
|
||||
async function performBackup() {
|
||||
try {
|
||||
log.info('Starting scheduled backup...');
|
||||
|
||||
await ensureBackupDir();
|
||||
|
||||
const fileName = backupFileName();
|
||||
const filePath = path.join(BACKUP_DIR, fileName);
|
||||
|
||||
await backupToFile(filePath);
|
||||
|
||||
log.info(`Backup created: ${fileName}`);
|
||||
|
||||
// Rotate old backups
|
||||
await rotateBackups();
|
||||
|
||||
lastBackup = {
|
||||
timestamp: new Date().toISOString(),
|
||||
file: fileName,
|
||||
success: true,
|
||||
};
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
log.error('Scheduled backup failed:', err);
|
||||
lastError = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: err.message,
|
||||
};
|
||||
lastBackup = {
|
||||
timestamp: new Date().toISOString(),
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backup scheduler
|
||||
*/
|
||||
export function startScheduler() {
|
||||
if (!BACKUP_ENABLED) {
|
||||
log.info('Automated backups are disabled (BACKUP_ENABLED=false)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(BACKUP_SCHEDULE)) {
|
||||
log.error(`Invalid cron schedule: ${BACKUP_SCHEDULE}`);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledTask = cron.schedule(BACKUP_SCHEDULE, performBackup, {
|
||||
timezone: process.env.TZ || 'UTC',
|
||||
});
|
||||
|
||||
log.info(`Backup scheduler started: ${BACKUP_SCHEDULE} (keeping last ${BACKUP_KEEP} backups)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the backup scheduler
|
||||
*/
|
||||
export function stopScheduler() {
|
||||
if (scheduledTask) {
|
||||
scheduledTask.stop();
|
||||
scheduledTask = null;
|
||||
log.info('Backup scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
export function getStatus() {
|
||||
return {
|
||||
enabled: BACKUP_ENABLED,
|
||||
schedule: BACKUP_SCHEDULE,
|
||||
backupDir: BACKUP_DIR,
|
||||
keepCount: BACKUP_KEEP,
|
||||
running: scheduledTask !== null,
|
||||
lastBackup,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate backup (for manual/testing purposes)
|
||||
*/
|
||||
export async function triggerBackup() {
|
||||
await performBackup();
|
||||
return lastBackup;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user