mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): move the auto-backup cron into the backup domain
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+58
-24
@@ -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');
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user