From c63d6430afa2a90b22542823795468f0e2f86138 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 10 Aug 2026 21:33:48 +0200 Subject: [PATCH] refactor(server): move the auto-backup cron into the backup domain --- server/src/index.ts | 3 - server/src/nest/backup/auto-backup.job.ts | 84 +++++++++ .../src/nest/backup/auto-backup.settings.ts | 88 ++++++++++ server/src/nest/backup/backup.controller.ts | 11 +- server/src/nest/backup/backup.impl.ts | 22 +-- server/src/nest/backup/backup.module.ts | 6 +- server/src/nest/backup/backup.service.ts | 2 - server/src/scheduler.ts | 138 +-------------- server/tests/e2e/backup.e2e.test.ts | 4 +- server/tests/integration/backup.test.ts | 19 ++- server/tests/unit/auto-backup.test.ts | 159 ++++++++++++++---- .../auto-backup-settings.test.ts} | 82 ++++++--- .../tests/unit/nest/backup.controller.test.ts | 28 ++- server/tests/unit/nest/backup.impl.test.ts | 72 +------- 14 files changed, 406 insertions(+), 312 deletions(-) create mode 100644 server/src/nest/backup/auto-backup.job.ts create mode 100644 server/src/nest/backup/auto-backup.settings.ts rename server/tests/unit/{scheduler.test.ts => nest/auto-backup-settings.test.ts} (75%) diff --git a/server/src/index.ts b/server/src/index.ts index d8d1a3249..c1c3563f8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,7 +9,6 @@ import fs from 'node:fs'; import http from 'node:http'; import type { INestApplication } from '@nestjs/common'; import { buildApp } from './bootstrap'; -import { BackupService } from './nest/backup/backup.service'; import { PlacePhotoCacheService } from './nest/place-photos/place-photo-cache.service'; import { AirtrailSyncService } from './nest/integrations/airtrail-sync.service'; @@ -94,7 +93,6 @@ const onListen = () => { // handed them here, from the app buildApp() already initialised, rather than // reaching for src/services at call time. scheduler.setSchedulerDeps({ - backups: nestApp.get(BackupService), // The container singleton, not a fresh instance: the in-flight dedup and the // known-on-disk set only work if the whole process shares one. placePhotos: nestApp.get(PlacePhotoCacheService), @@ -103,7 +101,6 @@ const onListen = () => { // belongs at boot. airtrail: nestApp.get(AirtrailSyncService), }); - scheduler.start(); scheduler.startTripReminders(); scheduler.startTodoReminders(); scheduler.startVersionCheck(); diff --git a/server/src/nest/backup/auto-backup.job.ts b/server/src/nest/backup/auto-backup.job.ts new file mode 100644 index 000000000..e74f490c7 --- /dev/null +++ b/server/src/nest/backup/auto-backup.job.ts @@ -0,0 +1,84 @@ +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; +import { readEnv } from '../../app-config'; +import { logInfo, logError } from '../audit/audit-log.logger'; +import { BackupService } from './backup.service'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { parseAutoBackupBody } from './backup.impl'; +import { + buildCronExpression, + cleanupOldBackups, + loadSettings, + saveSettings, + type BackupSettings, +} from './auto-backup.settings'; + +/** + * The auto-backup cron, in the domain that owns it (moved from + * src/scheduler.ts). The job is dynamic: its expression comes from + * data/backup-settings.json, so saving new settings re-arms it via start() — + * the same method onApplicationBootstrap uses, replacing the old + * scheduler.start() restart that backup.impl reached out of the container for. + */ +@Injectable() +export class AutoBackupJob implements OnApplicationBootstrap { + constructor( + private readonly backup: BackupService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + this.start(); + } + + /** (Re)arm the cron from the settings file — also the restart path after a settings save. */ + start(): void { + const settings = loadSettings(); + if (!settings.enabled) { + this.registrar.unregister('auto-backup'); + logInfo('Auto-Backup disabled'); + return; + } + + const expression = buildCronExpression(settings); + const tz = readEnv().app.tz || 'UTC'; + const armed = this.registrar.register('auto-backup', expression, () => this.runBackup()); + if (armed) { + logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); + } + } + + async runBackup(): Promise { + try { + // Same archive the admin panel builds, only under the auto-backup name: it + // snapshots travel.db with VACUUM INTO instead of archiving the live file + // (the archiver reads its entries during finalize, so a WAL auto-checkpoint + // landing in between tears the copy), and it carries .encryption_key and the + // plugin trees — without the key a scheduled backup cannot be decrypted on + // another install. + const { filename } = await this.backup.createBackup('auto-backup'); + logInfo(`Auto-Backup created: ${filename}`); + } catch (err: unknown) { + // createBackup removes its own half-written zip before rethrowing. + logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`); + return; + } + + const settings = loadSettings(); + if (settings.keep_days > 0) { + cleanupOldBackups(settings.keep_days); + } + } + + getAutoSettings(): { settings: BackupSettings; timezone: string } { + const tz = readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + return { settings: loadSettings(), timezone: tz }; + } + + updateAutoSettings(body: Record): BackupSettings { + const settings = parseAutoBackupBody(body); + saveSettings(settings); + this.start(); + return settings; + } +} diff --git a/server/src/nest/backup/auto-backup.settings.ts b/server/src/nest/backup/auto-backup.settings.ts new file mode 100644 index 000000000..176c574b0 --- /dev/null +++ b/server/src/nest/backup/auto-backup.settings.ts @@ -0,0 +1,88 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { logInfo, logError } from '../audit/audit-log.logger'; + +/** + * Auto-backup settings and retention — the pure half of the auto-backup cron + * (moved from src/scheduler.ts). Stays a plain module beside backup.impl.ts + * for the same reason that does: file I/O against data/backup-settings.json + * and data/backups, no container state. AutoBackupJob owns the scheduling. + */ + +const dataDir = path.join(__dirname, '../../../data'); +const backupsDir = path.join(dataDir, 'backups'); +const settingsFile = path.join(dataDir, 'backup-settings.json'); + +export const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly']; +const VALID_DAYS_OF_WEEK = new Set([0, 1, 2, 3, 4, 5, 6]); // 0=Sunday +const VALID_HOURS = new Set(Array.from({ length: 24 }, (_, i) => i)); + +export interface BackupSettings { + enabled: boolean; + interval: string; + keep_days: number; + hour: number; + day_of_week: number; + day_of_month: number; +} + +export function buildCronExpression(settings: BackupSettings): string { + const hour = VALID_HOURS.has(settings.hour) ? settings.hour : 2; + const dow = VALID_DAYS_OF_WEEK.has(settings.day_of_week) ? settings.day_of_week : 0; + const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1; + + switch (settings.interval) { + case 'hourly': return '0 * * * *'; + case 'daily': return `0 ${hour} * * *`; + case 'weekly': return `0 ${hour} * * ${dow}`; + case 'monthly': return `0 ${hour} ${dom} * *`; + default: return `0 ${hour} * * *`; + } +} + +function getDefaults(): BackupSettings { + return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }; +} + +export function loadSettings(): BackupSettings { + let settings = getDefaults(); + try { + if (fs.existsSync(settingsFile)) { + const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); + settings = { ...settings, ...saved }; + } + } catch { + /* corrupt settings file — fall back to the defaults */ + } + return settings; +} + +export function saveSettings(settings: BackupSettings): void { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); +} + +function autoBackupTimestampMs(filename: string): number | null { + // auto-backup-2026-04-27T00-00-00.zip → 2026-04-27T00:00:00 + const stamp = filename.slice('auto-backup-'.length, -'.zip'.length); + const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3'); + const ms = Date.parse(iso); + return Number.isNaN(ms) ? null : ms; +} + +export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void { + try { + const cutoff = now - keepDays * 24 * 60 * 60 * 1000; + const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip')); + for (const file of files) { + const filePath = path.join(backupsDir, file); + const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs; + if (ageMs < cutoff) { + fs.unlinkSync(filePath); + logInfo(`Auto-Backup old backup deleted: ${file}`); + } + } + } catch (err: unknown) { + logError(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/server/src/nest/backup/backup.controller.ts b/server/src/nest/backup/backup.controller.ts index 0cac39833..981286362 100644 --- a/server/src/nest/backup/backup.controller.ts +++ b/server/src/nest/backup/backup.controller.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import { readEnv } from '../../app-config'; import type { User } from '../../types'; import { BackupService } from './backup.service'; +import { AutoBackupJob } from './auto-backup.job'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -49,7 +50,11 @@ const UPLOAD = { @Controller('api/backup') @UseGuards(JwtAuthGuard, AdminGuard) export class BackupController { - constructor(private readonly backup: BackupService, private readonly audit: AuditService) {} + constructor( + private readonly backup: BackupService, + private readonly audit: AuditService, + private readonly autoBackup: AutoBackupJob, + ) {} @Get('list') list() { @@ -135,7 +140,7 @@ export class BackupController { @Get('auto-settings') autoSettings() { try { - return this.backup.getAutoSettings(); + return this.autoBackup.getAutoSettings(); } catch (err) { console.error('[backup] GET auto-settings:', err); throw new HttpException({ error: 'Could not load backup settings' }, 500); @@ -145,7 +150,7 @@ export class BackupController { @Put('auto-settings') updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) { try { - const settings = this.backup.updateAutoSettings(body || {}); + const settings = this.autoBackup.updateAutoSettings(body || {}); this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } }); return { settings }; } catch (err) { diff --git a/server/src/nest/backup/backup.impl.ts b/server/src/nest/backup/backup.impl.ts index 75da93a62..00b8d74b6 100644 --- a/server/src/nest/backup/backup.impl.ts +++ b/server/src/nest/backup/backup.impl.ts @@ -5,7 +5,7 @@ import { readEnv } from '../../app-config'; import fs from 'fs'; import Database from 'better-sqlite3'; import { db, closeDb, reinitialize } from '../../db/database'; -import * as scheduler from '../../scheduler'; +import { VALID_INTERVALS } from './auto-backup.settings'; import { invalidatePermissionsCache } from '../permissions/permissions.bridge'; import { pluginsCodeRoot, pluginsDataRoot } from '../plugins/paths'; import { stageExtractedPluginTrees, applyStagedRestoreNow } from '../plugins/plugin-backup'; @@ -66,7 +66,7 @@ export function parseAutoBackupBody(body: Record): { const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1; const rawInterval = body.interval; const interval = - typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) + typeof rawInterval === 'string' && VALID_INTERVALS.includes(rawInterval) ? rawInterval : 'daily'; const keep_days = Math.max(0, parseIntField(body.keep_days, 7)); @@ -146,7 +146,7 @@ export function listBackups(): BackupInfo[] { /** * Writes a full backup zip and returns its BackupInfo. * - * `prefix` picks the filename scheme. The scheduler passes 'auto-backup' because + * `prefix` picks the filename scheme. AutoBackupJob passes 'auto-backup' because * everything downstream tells the two apart by name: cleanupOldBackups() prunes * only auto-backup-*.zip, and the admin panel badges them as automatic. Manual * backups keep the default. @@ -478,22 +478,6 @@ export async function restoreFromZip(zipPath: string): Promise { } } -// --------------------------------------------------------------------------- -// Auto-backup settings -// --------------------------------------------------------------------------- - -export function getAutoSettings(): { settings: ReturnType; timezone: string } { - const tz = readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - return { settings: scheduler.loadSettings(), timezone: tz }; -} - -export function updateAutoSettings(body: Record): ReturnType { - const settings = parseAutoBackupBody(body); - scheduler.saveSettings(settings); - scheduler.start(); - return settings; -} - // --------------------------------------------------------------------------- // Delete backup // --------------------------------------------------------------------------- diff --git a/server/src/nest/backup/backup.module.ts b/server/src/nest/backup/backup.module.ts index 9d1a8765f..2e8d44cfd 100644 --- a/server/src/nest/backup/backup.module.ts +++ b/server/src/nest/backup/backup.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; +import { AutoBackupJob } from './auto-backup.job'; import { AuditModule } from '../audit/audit.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; @Module({ - imports: [AuditModule], + imports: [AuditModule, SchedulingModule], controllers: [BackupController], - providers: [BackupService], + providers: [BackupService, AutoBackupJob], }) export class BackupModule {} diff --git a/server/src/nest/backup/backup.service.ts b/server/src/nest/backup/backup.service.ts index 167c2c8b2..e112a85c1 100644 --- a/server/src/nest/backup/backup.service.ts +++ b/server/src/nest/backup/backup.service.ts @@ -16,8 +16,6 @@ export class BackupService { listBackups() { return svc.listBackups(); } createBackup(prefix?: 'backup' | 'auto-backup') { return svc.createBackup(prefix); } restoreFromZip(zipPath: string) { return svc.restoreFromZip(zipPath); } - getAutoSettings() { return svc.getAutoSettings(); } - updateAutoSettings(body: Record) { return svc.updateAutoSettings(body); } deleteBackup(filename: string) { return svc.deleteBackup(filename); } isValidBackupFilename(filename: string) { return svc.isValidBackupFilename(filename); } diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 51c6902db..8b5d5847f 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -1,78 +1,20 @@ import cron, { type ScheduledTask } from 'node-cron'; import { readEnv } from './app-config'; -import path from 'node:path'; -import fs from 'node:fs'; import { logInfo, logError } from './nest/audit/audit-log.logger'; -const dataDir = path.join(__dirname, '../data'); -const backupsDir = path.join(dataDir, 'backups'); -const settingsFile = path.join(dataDir, 'backup-settings.json'); - -const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly']; -const VALID_DAYS_OF_WEEK = new Set([0, 1, 2, 3, 4, 5, 6]); // 0=Sunday -const VALID_HOURS = new Set(Array.from({length: 24}, (_, i) => i)); - -interface BackupSettings { - enabled: boolean; - interval: string; - keep_days: number; - hour: number; - day_of_week: number; - day_of_month: number; -} - -export function buildCronExpression(settings: BackupSettings): string { - const hour = VALID_HOURS.has(settings.hour) ? settings.hour : 2; - const dow = VALID_DAYS_OF_WEEK.has(settings.day_of_week) ? settings.day_of_week : 0; - const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1; - - switch (settings.interval) { - case 'hourly': return '0 * * * *'; - case 'daily': return `0 ${hour} * * *`; - case 'weekly': return `0 ${hour} * * ${dow}`; - case 'monthly': return `0 ${hour} ${dom} * *`; - default: return `0 ${hour} * * *`; - } -} - -let currentTask: ScheduledTask | null = null; - -function getDefaults(): BackupSettings { - return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }; -} - -function loadSettings(): BackupSettings { - let settings = getDefaults(); - try { - if (fs.existsSync(settingsFile)) { - const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); - settings = { ...settings, ...saved }; - } - } catch (e) {} - return settings; -} - -function saveSettings(settings: BackupSettings): void { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); -} - /** * What the scheduler needs from the Nest container. * * The shapes are structural on purpose: the scheduler names the capability it - * wants, never the provider class. Importing BackupService here would drag in - * src/nest/backup → src/nest/backup/backup.impl → src/scheduler and put the - * cycle straight back — the same cycle the lazy `await import()` in runBackup - * existed to dodge. + * wants, never the provider class — importing a provider class here would pull + * its module graph into a file that must stay loadable without a container + * (which is what the unit tests rely on). * * index.ts fills this in from the container after buildApp(), the way * bootstrap.ts hands the /mcp handler its registry. Nothing here is resolved at - * import time, so the scheduler stays loadable without a container (which is - * what its unit tests rely on). + * import time. */ export interface SchedulerDeps { - backups: { createBackup(prefix?: 'backup' | 'auto-backup'): Promise<{ filename: string }> }; placePhotos: { sweepOrphans(): number }; airtrail: { runAirtrailSync(): Promise }; } @@ -84,75 +26,6 @@ export function setSchedulerDeps(next: SchedulerDeps): void { deps = next; } -async function runBackup(): Promise { - try { - // Same archive the admin panel builds, only under the auto-backup name: it - // snapshots travel.db with VACUUM INTO instead of archiving the live file - // (the archiver reads its entries during finalize, so a WAL auto-checkpoint - // landing in between tears the copy), and it carries .encryption_key and the - // plugin trees — without the key a scheduled backup cannot be decrypted on - // another install. - if (!deps) { - logError('Auto-Backup: skipped, the scheduler was started without its container dependencies'); - return; - } - const { filename } = await deps.backups.createBackup('auto-backup'); - logInfo(`Auto-Backup created: ${filename}`); - } catch (err: unknown) { - // createBackup removes its own half-written zip before rethrowing. - logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`); - return; - } - - const settings = loadSettings(); - if (settings.keep_days > 0) { - cleanupOldBackups(settings.keep_days); - } -} - -function autoBackupTimestampMs(filename: string): number | null { - // auto-backup-2026-04-27T00-00-00.zip → 2026-04-27T00:00:00 - const stamp = filename.slice('auto-backup-'.length, -'.zip'.length); - const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3'); - const ms = Date.parse(iso); - return Number.isNaN(ms) ? null : ms; -} - -export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void { - try { - const cutoff = now - keepDays * 24 * 60 * 60 * 1000; - const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip')); - for (const file of files) { - const filePath = path.join(backupsDir, file); - const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs; - if (ageMs < cutoff) { - fs.unlinkSync(filePath); - logInfo(`Auto-Backup old backup deleted: ${file}`); - } - } - } catch (err: unknown) { - logError(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`); - } -} - -function start(): void { - if (currentTask) { - currentTask.stop(); - currentTask = null; - } - - const settings = loadSettings(); - if (!settings.enabled) { - logInfo('Auto-Backup disabled'); - return; - } - - const expression = buildCronExpression(settings); - const tz = readEnv().app.tz || 'UTC'; - currentTask = cron.schedule(expression, runBackup, { timezone: tz }); - logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`); -} - // Demo mode: hourly reset of demo user data let demoTask: ScheduledTask | null = null; @@ -447,7 +320,6 @@ function startAirTrailSync(): void { } function stop(): void { - if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } if (reminderTask) { reminderTask.stop(); reminderTask = null; } if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } @@ -457,4 +329,4 @@ function stop(): void { if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS }; +export { stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync }; diff --git a/server/tests/e2e/backup.e2e.test.ts b/server/tests/e2e/backup.e2e.test.ts index 217f99c61..c6010c92d 100644 --- a/server/tests/e2e/backup.e2e.test.ts +++ b/server/tests/e2e/backup.e2e.test.ts @@ -33,8 +33,8 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', lo const { backupSvc } = vi.hoisted(() => ({ backupSvc: { - listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), getAutoSettings: vi.fn(), - updateAutoSettings: vi.fn(), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(), + listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), parseAutoBackupBody: vi.fn(), + deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(), backupFileExists: vi.fn(), checkRateLimit: vi.fn(), getUploadTmpDir: () => '/tmp', BACKUP_RATE_WINDOW: 3600000, MAX_BACKUP_UPLOAD_SIZE: 1024, }, diff --git a/server/tests/integration/backup.test.ts b/server/tests/integration/backup.test.ts index a49234918..c462e79d2 100644 --- a/server/tests/integration/backup.test.ts +++ b/server/tests/integration/backup.test.ts @@ -58,14 +58,6 @@ vi.mock('../../src/nest/backup/backup.impl', async () => { sizeText: '1.0 KB', created_at: new Date().toISOString(), }), - updateAutoSettings: vi.fn().mockReturnValue({ - enabled: false, - interval: 'daily', - keep_days: 7, - hour: 2, - day_of_week: 0, - day_of_month: 1, - }), restoreFromZip: vi.fn().mockResolvedValue({ success: true }), deleteBackup: vi.fn().mockReturnValue(undefined), backupFileExists: vi.fn().mockReturnValue(false), @@ -75,6 +67,17 @@ vi.mock('../../src/nest/backup/backup.impl', async () => { }; }); +// The auto-settings routes live on AutoBackupJob now; keep its settings-file +// I/O off the real data/ dir (the scheduling itself is off under the test gate). +vi.mock('../../src/nest/backup/auto-backup.settings', async () => { + const actual = await vi.importActual('../../src/nest/backup/auto-backup.settings'); + return { + ...actual, + loadSettings: vi.fn(() => ({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })), + saveSettings: vi.fn(), + }; +}); + import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; diff --git a/server/tests/unit/auto-backup.test.ts b/server/tests/unit/auto-backup.test.ts index 30e2472ac..95116655f 100644 --- a/server/tests/unit/auto-backup.test.ts +++ b/server/tests/unit/auto-backup.test.ts @@ -1,5 +1,5 @@ /** - * Scheduled backup run. + * Scheduled backup run (AutoBackupJob). * * The auto-backup used to archive the live travel.db: the archiver reads its * entries while streaming, so a WAL auto-checkpoint writing pages back mid-run @@ -49,9 +49,6 @@ const dbMock = vi.hoisted(() => ({ const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() })); -const cronMock = vi.hoisted(() => ({ schedule: vi.fn(), validate: vi.fn(() => true) })); - -vi.mock('node-cron', () => ({ default: cronMock, ...cronMock })); vi.mock('fs', () => ({ default: fsMock, ...fsMock })); vi.mock('node:fs', () => ({ default: fsMock, ...fsMock })); vi.mock('archiver', () => ({ default: archiverMock })); @@ -65,11 +62,39 @@ vi.mock('../../src/config', () => ({ })); import path from 'node:path'; -import { start, setSchedulerDeps } from '../../src/scheduler'; +import { AutoBackupJob } from '../../src/nest/backup/auto-backup.job'; import { createBackup } from '../../src/nest/backup/backup.impl'; +import type { BackupService } from '../../src/nest/backup/backup.service'; +import type { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service'; const liveDb = path.join(__dirname, '../../data', 'travel.db'); +interface Registered { + name: string; + expr: string; + onTick: () => Promise | void; +} + +/** An AutoBackupJob wired to the real createBackup and a capturing registrar double. */ +function makeJob() { + const registered: Registered[] = []; + const registrar = { + isEnabled: vi.fn(() => true), + register: vi.fn((name: string, expr: string, onTick: Registered['onTick']) => { + registered.push({ name, expr, onTick }); + return true; + }), + unregister: vi.fn(), + }; + const job = new AutoBackupJob( + // The same wiring the container does, with the real service function behind + // it — the run below is still the production code path. + { createBackup } as unknown as BackupService, + registrar as unknown as CronRegistrarService, + ); + return { job, registered, registrar }; +} + /** Wires up the archiver so finalize() resolves the run, and returns its events. */ function stubArchiver(): Record void> { const outputEvents: Record void> = {}; @@ -83,21 +108,12 @@ function stubArchiver(): Record void> { return archiveEvents; } -/** Starts the scheduler with auto-backup enabled and hands back the cron callback. */ +/** Arms the job with auto-backup enabled and hands back the cron tick. */ function scheduledRun(): () => Promise { fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 })); - // The same wiring index.ts does from the container, with the real service - // function behind it — the run below is still the production code path. - // Only the backup tick is started here; the scheduler's other two container - // collaborators are stubbed so the deps object is complete, and neither the - // place-photo sweep nor the AirTrail tick is scheduled by start(). - setSchedulerDeps({ - backups: { createBackup }, - placePhotos: { sweepOrphans: vi.fn(() => 0) }, - airtrail: { runAirtrailSync: vi.fn(async () => {}) }, - }); - start(); - return cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise; + const { job, registered } = makeJob(); + job.start(); + return registered.at(-1)?.onTick as () => Promise; } describe('auto-backup run', () => { @@ -169,21 +185,102 @@ describe('auto-backup run', () => { }); }); -describe('auto-backup without container dependencies', () => { - it('logs and skips instead of throwing when setSchedulerDeps was never called', async () => { - // A fresh module registry, so the deps this file's other cases install are - // not still sitting in the scheduler's module state. - vi.resetModules(); - const { start: freshStart } = await import('../../src/scheduler'); - +describe('auto-backup scheduling (AutoBackupJob.start)', () => { + beforeEach(() => { vi.clearAllMocks(); - fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 })); - freshStart(); - await (cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise)(); + fsMock.existsSync.mockImplementation(() => false); + fsMock.readFileSync.mockReturnValue('{}'); + }); - expect(logMock.logError).toHaveBeenCalledWith( - 'Auto-Backup: skipped, the scheduler was started without its container dependencies', + it('disabled settings unregister the job and log Auto-Backup disabled', () => { + const { job, registrar } = makeJob(); + job.start(); + expect(registrar.unregister).toHaveBeenCalledWith('auto-backup'); + expect(registrar.register).not.toHaveBeenCalled(); + expect(logMock.logInfo).toHaveBeenCalledWith('Auto-Backup disabled'); + }); + + it('enabled settings register the built expression under the auto-backup name and log the banner', () => { + fsMock.existsSync.mockImplementation((p: PathLike) => String(p).endsWith('backup-settings.json')); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7, hour: 2 })); + const { job, registered } = makeJob(); + job.start(); + expect(registered.at(-1)?.name).toBe('auto-backup'); + expect(registered.at(-1)?.expr).toBe('0 2 * * *'); + expect(logMock.logInfo).toHaveBeenCalledWith( + expect.stringMatching(/^Auto-Backup scheduled: daily \(0 2 \* \* \*\), tz: .+, retention: 7 days$/), ); - expect(archiverMock).not.toHaveBeenCalled(); + }); + + it('skips the banner when the registrar refuses to arm (test gate)', () => { + fsMock.existsSync.mockImplementation((p: PathLike) => String(p).endsWith('backup-settings.json')); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 })); + const { job, registrar } = makeJob(); + registrar.register.mockImplementation(() => false); + job.start(); + expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringContaining('Auto-Backup scheduled')); + }); + + it('onApplicationBootstrap does nothing when the registrar is disabled', () => { + const { job, registrar } = makeJob(); + registrar.isEnabled.mockReturnValue(false); + job.onApplicationBootstrap(); + expect(registrar.register).not.toHaveBeenCalled(); + expect(registrar.unregister).not.toHaveBeenCalled(); + expect(logMock.logInfo).not.toHaveBeenCalled(); + }); +}); + +// Moved from backup.impl.test.ts when updateAutoSettings moved onto the job +// (the impl used to call scheduler.saveSettings + scheduler.start()). +describe('BACKUP-047 updateAutoSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + fsMock.existsSync.mockImplementation(() => false); + fsMock.readFileSync.mockReturnValue('{}'); + }); + + it('BACKUP-047a — persists the parsed settings to backup-settings.json', () => { + const { job } = makeJob(); + job.updateAutoSettings({ enabled: true, interval: 'weekly', hour: 6 }); + + expect(fsMock.writeFileSync).toHaveBeenCalledOnce(); + const [file, payload] = fsMock.writeFileSync.mock.calls[0] as [string, string]; + expect(file).toContain('backup-settings.json'); + expect(JSON.parse(payload)).toMatchObject({ enabled: true, interval: 'weekly', hour: 6 }); + }); + + it('BACKUP-047b — re-arms the job only after saving', () => { + const order: string[] = []; + fsMock.writeFileSync.mockImplementation(() => { order.push('save'); }); + const { job, registrar } = makeJob(); + // Saving {enabled:false} sends start() down the disabled path — unregister + // is its scheduling action, so it stands in for the old scheduler.start(). + registrar.unregister.mockImplementation(() => { order.push('start'); }); + + job.updateAutoSettings({ enabled: false }); + + expect(order).toEqual(['save', 'start']); + }); + + it('BACKUP-047c — returns the parsed settings object', () => { + const { job } = makeJob(); + const result = job.updateAutoSettings({ + enabled: true, + interval: 'monthly', + keep_days: 30, + hour: 3, + day_of_week: 2, + day_of_month: 15, + }); + + expect(result).toEqual({ + enabled: true, + interval: 'monthly', + keep_days: 30, + hour: 3, + day_of_week: 2, + day_of_month: 15, + }); }); }); diff --git a/server/tests/unit/scheduler.test.ts b/server/tests/unit/nest/auto-backup-settings.test.ts similarity index 75% rename from server/tests/unit/scheduler.test.ts rename to server/tests/unit/nest/auto-backup-settings.test.ts index f75801951..c3ace2465 100644 --- a/server/tests/unit/scheduler.test.ts +++ b/server/tests/unit/nest/auto-backup-settings.test.ts @@ -1,13 +1,9 @@ +/** + * Auto-backup settings + retention (moved from tests/unit/scheduler.test.ts + * when the code moved from src/scheduler.ts into the backup domain). + */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -// Prevent node-cron from scheduling anything at import time -vi.mock('node-cron', () => ({ - default: { schedule: vi.fn(), validate: vi.fn(() => true) }, - schedule: vi.fn(), - validate: vi.fn(() => true), -})); -// Prevent archiver from causing side effects -vi.mock('archiver', () => ({ default: vi.fn() })); // Prevent fs side effects (creating directories, reading files) vi.mock('node:fs', () => ({ default: { @@ -29,22 +25,28 @@ vi.mock('node:fs', () => ({ unlinkSync: vi.fn(), createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })), })); -vi.mock('../../../src/db/database', () => ({ - db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) }, -})); -vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -vi.mock('../../src/nest/audit/audit-log.logger', () => ({ +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logInfo: vi.fn(), logError: vi.fn(), })); import fs from 'node:fs'; -import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler'; +import { + buildCronExpression, + cleanupOldBackups, + loadSettings, + saveSettings, + type BackupSettings, +} from '../../../src/nest/backup/auto-backup.settings'; // readdirSync and statSync are overloaded in node:fs, and vi.mocked() picks the last -// overload (Dirent[] / BigIntStats) rather than the one the scheduler calls. These handles +// overload (Dirent[] / BigIntStats) rather than the one the cleanup calls. These handles // point at the very same mock functions from the factory above, pinned to the plain // string[] / Stats signatures that cleanupOldBackups actually uses. +const existsSyncMock = fs.existsSync as unknown as Mock<(path: string) => boolean>; +const readFileSyncMock = fs.readFileSync as unknown as Mock<(path: string, enc: string) => string>; +const writeFileSyncMock = fs.writeFileSync as unknown as Mock<(path: string, data: string) => void>; +const mkdirSyncMock = fs.mkdirSync as unknown as Mock<(path: string, opts?: unknown) => void>; const readdirSyncMock = fs.readdirSync as unknown as Mock<(path: string) => string[]>; const statSyncMock = fs.statSync as unknown as Mock<(path: string) => fs.Stats>; const unlinkSyncMock = fs.unlinkSync as unknown as Mock<(path: string) => void>; @@ -55,15 +57,6 @@ function statStub(partial: Partial): fs.Stats { return partial as fs.Stats; } -interface BackupSettings { - enabled: boolean; - interval: string; - keep_days: number; - hour: number; - day_of_week: number; - day_of_month: number; -} - function settings(overrides: Partial = {}): BackupSettings { return { enabled: true, @@ -152,6 +145,47 @@ describe('buildCronExpression', () => { }); }); +describe('loadSettings / saveSettings', () => { + beforeEach(() => { + existsSyncMock.mockReset().mockReturnValue(false); + readFileSyncMock.mockReset().mockReturnValue('{}'); + writeFileSyncMock.mockReset(); + mkdirSyncMock.mockReset(); + }); + + it('returns the defaults when no settings file exists', () => { + expect(loadSettings()).toEqual({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }); + }); + + it('merges the saved file over the defaults', () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue(JSON.stringify({ enabled: true, interval: 'weekly', hour: 6 })); + expect(loadSettings()).toEqual({ enabled: true, interval: 'weekly', keep_days: 7, hour: 6, day_of_week: 0, day_of_month: 1 }); + }); + + it('falls back to the defaults on a corrupt settings file', () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue('{not json'); + expect(loadSettings()).toEqual({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }); + }); + + it('saveSettings creates the data dir when missing and writes pretty JSON', () => { + const s = settings({ enabled: true, interval: 'weekly' }); + saveSettings(s); + expect(mkdirSyncMock).toHaveBeenCalledWith(expect.stringContaining('data'), { recursive: true }); + expect(writeFileSyncMock).toHaveBeenCalledWith( + expect.stringContaining('backup-settings.json'), + JSON.stringify(s, null, 2), + ); + }); + + it('saveSettings skips the mkdir when the data dir already exists', () => { + existsSyncMock.mockReturnValue(true); + saveSettings(settings()); + expect(mkdirSyncMock).not.toHaveBeenCalled(); + }); +}); + describe('cleanupOldBackups', () => { const DAY = 24 * 60 * 60 * 1000; const NOW = new Date('2026-04-27T02:00:00Z').getTime(); diff --git a/server/tests/unit/nest/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index 8ae3c6baf..47733fe5e 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -14,8 +14,6 @@ vi.mock('../../../src/nest/backup/backup.impl', () => ({ listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]), createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }), restoreFromZip: vi.fn().mockResolvedValue({ success: true }), - getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }), - updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn().mockReturnValue(true), backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'), @@ -27,6 +25,7 @@ import { BackupController } from '../../../src/nest/backup/backup.controller'; import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service'; import { AdminGuard } from '../../../src/nest/auth/admin.guard'; import type { BackupService } from '../../../src/nest/backup/backup.service'; +import type { AutoBackupJob } from '../../../src/nest/backup/auto-backup.job'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import * as backupSvc from '../../../src/nest/backup/backup.impl'; import type { User } from '../../../src/types'; @@ -38,7 +37,12 @@ const req = { ip: '1.2.3.4', headers: {} } as Request; // wrapper keeps the historical construction sites positional. const writeAudit = vi.fn(); const audit = { writeAudit } as unknown as AuditService; -const bc = (s: BackupService) => new BackupController(s, audit); +// The auto-settings routes live on AutoBackupJob since the cron moved into the +// backup domain; every other route still goes through BackupService. +function job(o: Partial = {}): AutoBackupJob { + return { getAutoSettings: vi.fn(), updateAutoSettings: vi.fn(), start: vi.fn(), ...o } as unknown as AutoBackupJob; +} +const bc = (s: BackupService, j: AutoBackupJob = job()) => new BackupController(s, audit, j); function svc(o: Partial = {}): BackupService { return { @@ -150,13 +154,13 @@ describe('BackupController', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); expect(await thrownAsync(() => bc(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } }); expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } }); - expect(thrown(() => bc(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } }); + expect(thrown(() => bc(svc(), job({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) })).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } }); }); it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'development'; - const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial)).updateAutoSettings(user, {}, req)); + const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) })).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' }); }); @@ -164,20 +168,20 @@ describe('BackupController', () => { it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); process.env.NODE_ENV = 'production'; - const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial)).updateAutoSettings(user, {}, req)); + const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) })).updateAutoSettings(user, {}, req)); expect(r.status).toBe(500); expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined }); }); it('PUT /auto-settings tolerates a missing body', () => { const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 }); - bc(svc({ updateAutoSettings } as Partial)).updateAutoSettings(user, undefined as unknown as Record, req); + bc(svc(), job({ updateAutoSettings })).updateAutoSettings(user, undefined as unknown as Record, req); expect(updateAutoSettings).toHaveBeenCalledWith({}); }); it('GET/PUT /auto-settings', () => { - expect(bc(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); - const res = bc(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial)).updateAutoSettings(user, { enabled: true }, req); + expect(bc(svc(), job({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) as never })).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' }); + const res = bc(svc(), job({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) as never })).updateAutoSettings(user, { enabled: true }, req); expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' })); }); @@ -204,12 +208,6 @@ describe('BackupService (wrapper)', () => { await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true }); expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip'); - expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' }); - expect(backupSvc.getAutoSettings).toHaveBeenCalled(); - - expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 }); - expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true }); - wrapper.deleteBackup('svc.zip'); expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip'); diff --git a/server/tests/unit/nest/backup.impl.test.ts b/server/tests/unit/nest/backup.impl.test.ts index 8e9f3ffde..62a8a143d 100644 --- a/server/tests/unit/nest/backup.impl.test.ts +++ b/server/tests/unit/nest/backup.impl.test.ts @@ -64,20 +64,6 @@ vi.mock('../../../src/config', () => ({ vi.mock('fs', () => ({ default: fsMock, ...fsMock })); vi.mock('archiver', () => ({ default: archiverMock })); vi.mock('unzipper', () => ({ default: unzipperMock })); -vi.mock('../../../src/scheduler', () => ({ - VALID_INTERVALS: ['hourly', 'daily', 'weekly', 'monthly'], - loadSettings: vi.fn(() => ({ - enabled: false, - interval: 'daily', - keep_days: 7, - hour: 2, - day_of_week: 0, - day_of_month: 1, - })), - saveSettings: vi.fn(), - start: vi.fn(), -})); - import { formatSize, parseIntField, @@ -91,7 +77,6 @@ import { backupFilePath, backupFileExists, listBackups, - updateAutoSettings, } from '../../../src/nest/backup/backup.impl'; // --------------------------------------------------------------------------- @@ -1353,58 +1338,5 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => { }); }); -// --------------------------------------------------------------------------- -// updateAutoSettings -// --------------------------------------------------------------------------- - -describe('BACKUP-047 updateAutoSettings', () => { - let schedulerMock: typeof import('../../../src/scheduler'); - - beforeEach(async () => { - vi.clearAllMocks(); - schedulerMock = await import('../../../src/scheduler'); - }); - - it('BACKUP-047a — calls scheduler.saveSettings with the parsed settings', () => { - updateAutoSettings({ enabled: true, interval: 'weekly', hour: 6 }); - - expect(schedulerMock.saveSettings).toHaveBeenCalledOnce(); - expect(schedulerMock.saveSettings).toHaveBeenCalledWith( - expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 }) - ); - }); - - it('BACKUP-047b — calls scheduler.start() after saving', () => { - const saveOrder: string[] = []; - (schedulerMock.saveSettings as ReturnType).mockImplementation(() => { - saveOrder.push('saveSettings'); - }); - (schedulerMock.start as ReturnType).mockImplementation(() => { - saveOrder.push('start'); - }); - - updateAutoSettings({ enabled: false }); - - expect(saveOrder).toEqual(['saveSettings', 'start']); - }); - - it('BACKUP-047c — returns the parsed settings object', () => { - const result = updateAutoSettings({ - enabled: true, - interval: 'monthly', - keep_days: 30, - hour: 3, - day_of_week: 2, - day_of_month: 15, - }); - - expect(result).toEqual({ - enabled: true, - interval: 'monthly', - keep_days: 30, - hour: 3, - day_of_week: 2, - day_of_month: 15, - }); - }); -}); +// BACKUP-047 (updateAutoSettings) moved to tests/unit/auto-backup.test.ts with +// the function — it lives on AutoBackupJob now.