refactor(server): move the auto-backup cron into the backup domain

This commit is contained in:
jubnl
2026-08-10 21:33:48 +02:00
parent 399bbda0fa
commit c63d6430af
14 changed files with 406 additions and 312 deletions
-3
View File
@@ -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();
+84
View File
@@ -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<void> {
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<string, unknown>): BackupSettings {
const settings = parseAutoBackupBody(body);
saveSettings(settings);
this.start();
return settings;
}
}
@@ -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}`);
}
}
+8 -3
View File
@@ -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) {
+3 -19
View File
@@ -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<string, unknown>): {
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<RestoreResult> {
}
}
// ---------------------------------------------------------------------------
// Auto-backup settings
// ---------------------------------------------------------------------------
export function getAutoSettings(): { settings: ReturnType<typeof scheduler.loadSettings>; timezone: string } {
const tz = readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
return { settings: scheduler.loadSettings(), timezone: tz };
}
export function updateAutoSettings(body: Record<string, unknown>): ReturnType<typeof parseAutoBackupBody> {
const settings = parseAutoBackupBody(body);
scheduler.saveSettings(settings);
scheduler.start();
return settings;
}
// ---------------------------------------------------------------------------
// Delete backup
// ---------------------------------------------------------------------------
+4 -2
View File
@@ -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 {}
-2
View File
@@ -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<string, unknown>) { return svc.updateAutoSettings(body); }
deleteBackup(filename: string) { return svc.deleteBackup(filename); }
isValidBackupFilename(filename: string) { return svc.isValidBackupFilename(filename); }
+5 -133
View File
@@ -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<void> };
}
@@ -84,75 +26,6 @@ export function setSchedulerDeps(next: SchedulerDeps): void {
deps = next;
}
async function runBackup(): Promise<void> {
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 };
+2 -2
View File
@@ -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,
},
+11 -8
View File
@@ -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<typeof import('../../src/nest/backup/auto-backup.settings')>('../../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';
+128 -31
View File
@@ -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> | 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<string, (arg?: unknown) => void> {
const outputEvents: Record<string, (arg?: unknown) => void> = {};
@@ -83,21 +108,12 @@ function stubArchiver(): Record<string, (arg?: unknown) => 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<void> {
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<void>;
const { job, registered } = makeJob();
job.start();
return registered.at(-1)?.onTick as () => Promise<void>;
}
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<void>)();
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,
});
});
});
@@ -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>): 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> = {}): 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();
@@ -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> = {}): 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> = {}): 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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).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<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
bc(svc(), job({ updateAutoSettings })).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
expect(updateAutoSettings).toHaveBeenCalledWith({});
});
it('GET/PUT /auto-settings', () => {
expect(bc(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
const res = bc(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).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');
+2 -70
View File
@@ -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<typeof vi.fn>).mockImplementation(() => {
saveOrder.push('saveSettings');
});
(schedulerMock.start as ReturnType<typeof vi.fn>).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.