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
+7
View File
@@ -33,6 +33,13 @@ APPLE_APP_SPECIFIC_PASSWORD=
# Calendar sync interval in minutes (default: 15) # Calendar sync interval in minutes (default: 15)
SYNC_INTERVAL_MINUTES=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 # Security
RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_ATTEMPTS=5 RATE_LIMIT_MAX_ATTEMPTS=5
+10
View File
@@ -16,6 +16,7 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"node-cron": "^4.2.1",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1655,6 +1656,15 @@
"node": "^18 || ^20 || >= 21" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+3 -1
View File
@@ -26,7 +26,8 @@
"test:setup": "node test-setup.js", "test:setup": "node test-setup.js",
"test:ics-parser": "node test-ics-parser.js", "test:ics-parser": "node test-ics-parser.js",
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.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": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -36,6 +37,7 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"node-cron": "^4.2.1",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"
}, },
"optionalDependencies": { "optionalDependencies": {
+16 -1
View File
@@ -973,7 +973,22 @@
"backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...", "backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...",
"backupCliTitle": "CLI / Docker-Compose-Wiederherstellung", "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.", "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": { "login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
+16 -1
View File
@@ -967,7 +967,22 @@
"backupRestoredToast": "Database restored. Reloading...", "backupRestoredToast": "Database restored. Reloading...",
"backupCliTitle": "CLI / Docker Compose restore", "backupCliTitle": "CLI / Docker Compose restore",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.", "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": { "login": {
"tagline": "Family planning. Secure. Privacy-friendly. Open source.", "tagline": "Family planning. Secure. Privacy-friendly. Open source.",
+84
View File
@@ -798,6 +798,14 @@ export async function render(container, { user }) {
</div> </div>
</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"> <div class="settings-card">
<h3 class="settings-card__title">${t('settings.backupCliTitle')}</h3> <h3 class="settings-card__title">${t('settings.backupCliTitle')}</h3>
<p class="form-hint">${t('settings.backupCliHint')}</p> <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) { function bindBackupEvents(container) {
// Scheduler-Status laden und anzeigen
loadBackupSchedulerStatus(container);
const form = container.querySelector('#backup-restore-form'); const form = container.querySelector('#backup-restore-form');
const fileInput = container.querySelector('#backup-restore-file'); const fileInput = container.querySelector('#backup-restore-file');
const selectedFile = container.querySelector('#backup-selected-file'); const selectedFile = container.querySelector('#backup-selected-file');
+48
View File
@@ -724,3 +724,51 @@
.cat-add-form .form-input { .cat-add-form .form-input {
flex: 1; 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);
}
+4
View File
@@ -17,6 +17,7 @@ import { buildOpenApiSpec } from './openapi.js';
import * as googleCalendar from './services/google-calendar.js'; import * as googleCalendar from './services/google-calendar.js';
import * as appleCalendar from './services/apple-calendar.js'; import * as appleCalendar from './services/apple-calendar.js';
import * as icsSubscription from './services/ics-subscription.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 dashboardRouter from './routes/dashboard.js';
import tasksRouter from './routes/tasks.js'; import tasksRouter from './routes/tasks.js';
import shoppingRouter from './routes/shopping.js'; import shoppingRouter from './routes/shopping.js';
@@ -274,6 +275,9 @@ app.listen(PORT, () => {
setInterval(runSync, SYNC_INTERVAL_MS); setInterval(runSync, SYNC_INTERVAL_MS);
logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`); logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`);
}, 10_000); }, 10_000);
// Backup-Scheduler starten
startBackupScheduler();
}); });
export default app; export default app;
+13
View File
@@ -11,6 +11,7 @@ import fs from 'node:fs/promises';
import { backupToFile, currentVersion, restoreFromFile } from '../db.js'; import { backupToFile, currentVersion, restoreFromFile } from '../db.js';
import { requireAdmin } from '../auth.js'; import { requireAdmin } from '../auth.js';
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
import { getStatus as getSchedulerStatus, triggerBackup } from '../services/backup-scheduler.js';
const router = express.Router(); const router = express.Router();
const log = createLogger('Backup'); const log = createLogger('Backup');
@@ -22,10 +23,12 @@ function backupFileName() {
} }
router.get('/status', requireAdmin, (req, res) => { router.get('/status', requireAdmin, (req, res) => {
const schedulerStatus = getSchedulerStatus();
res.json({ res.json({
data: { data: {
schema_version: currentVersion(), schema_version: currentVersion(),
restore_upload_limit: RESTORE_LIMIT, 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) => { router.use((err, req, res, next) => {
if (err?.type === 'entity.too.large') { 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 }); return res.status(413).json({ error: `Backup file is too large. Maximum upload size is ${RESTORE_LIMIT}.`, code: 413 });
+189
View File
@@ -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;
}
+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 });
});
});