'use strict'; const test = require('node:test'), assert = require('node:assert/strict'); const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'); const { spawn, spawnSync } = require('child_process'); const { BackupService, readArchive, validateSettings, nextOccurrence } = require('../backup'); const root = path.resolve(__dirname, '..'); const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const quiet = { log() {}, warn() {}, error() {} }; const LONG = 'collection-covers/' + 'a-very-long-folder-name-that-keeps-going-'.repeat(3) + '/cover-with-a-long-name.png'; function fixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-backup-')); const data = path.join(dir, 'data'), target = path.join(dir, 'backup'); const write = (rel, text) => { fs.mkdirSync(path.dirname(path.join(data, rel)), { recursive: true }); fs.writeFileSync(path.join(data, rel), text); }; write('db.json', '{"stale":true}'); write('scan-cache.json', '{"cache":1}'); write('library-index.json', '{"version":1}'); write('catalog.json', '{"collections":[]}'); write('scraper/queue.json', JSON.stringify({ version: 3, paused: false, status: 'idle', queue: [{ id: 'a' }] })); write('scraper/events.log', '{"level":"info"}\n'); write('sentinel/state.json', '{}'); write(LONG, 'png-bytes'); // Must not be archived. write('runtime-reader.json', '{}'); write('scraper/runtime.json', '{}'); write('sentinel/runtime.json', '{}'); write('scraper/daemon.lock', '1'); write('scraper/supervisor.lock', '1'); write('reader.pid', '1'); write('db.json.tmp', '{}'); fs.mkdirSync(target); return { dir, data, target, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; } function fakeScraper({ paused = false, busyPolls = 0, failResume = false } = {}) { const calls = []; let polls = 0; return { calls, get paused() { return paused; }, async state() { polls++; return { paused, status: paused && polls <= busyPolls ? 'pausing' : paused ? 'paused' : 'downloading', active: polls <= busyPolls ? [{ source: 'x' }] : [] }; }, async control(action) { calls.push(action); if (action === 'resume' && failResume) throw new Error('scraper gone'); paused = action === 'pause'; }, }; } const service = (f, options = {}) => new BackupService({ dataRoot: f.data, backupRoot: f.target, log: quiet, pollMs: 5, resumeRetryMs: 1, snapshotDb: () => JSON.stringify({ users: { admin: {} }, live: true }), ...options }).load(); test('settings validation and schedule computation', () => { assert.deepEqual(validateSettings({ enabled: true, time: '04:15', retention: { mode: 'days', value: 30 } }), { enabled: true, time: '04:15', retention: { mode: 'days', value: 30 } }); assert.throws(() => validateSettings({ time: '24:00' }), /HH:MM/); assert.throws(() => validateSettings({ retention: { mode: 'count', value: 0 } }), /Keep/); assert.throws(() => validateSettings({ retention: { mode: 'weeks', value: 2 } }), /count/); assert.throws(() => validateSettings({ enabled: 'yes' }), /enabled/); const now = new Date(2026, 9, 22, 3, 0).getTime(); assert.equal(nextOccurrence('04:00', now), new Date(2026, 9, 22, 4, 0).getTime()); assert.equal(nextOccurrence('03:00', now), new Date(2026, 9, 23, 3, 0).getTime()); }); test('a backup pauses a running queue, waits for idle, archives a consistent DATA snapshot and resumes', async () => { const f = fixture(); try { const scraper = fakeScraper({ busyPolls: 3 }); const backup = service(f, { scraper }); const result = await backup.run('manual'); assert.equal(result.error, undefined, result.error); assert.deepEqual(scraper.calls, ['pause', 'resume']); assert.equal(result.queueWasPaused, false); assert.equal(result.queueResumed, true); assert.match(result.file, /^circus-\d{8}-\d{6}\.tar\.gz$/); assert.deepEqual(fs.readdirSync(f.target), [result.file]); const entries = await readArchive(path.join(f.target, result.file), { contents: true }); const byName = new Map(entries.map(e => [e.name, e])); const text = name => byName.get(name).data.toString('utf8'); // db.json comes from memory, not from the stale file on disk. assert.deepEqual(JSON.parse(text('db.json')), { users: { admin: {} }, live: true }); // The archived queue is paused; the live one is untouched by the snapshot. const queue = JSON.parse(text('scraper/queue.json')); assert.equal(queue.paused, true); assert.deepEqual(queue.queue, [{ id: 'a' }]); assert.equal(JSON.parse(fs.readFileSync(path.join(f.data, 'scraper/queue.json'), 'utf8')).paused, false); for (const name of ['scan-cache.json', 'library-index.json', 'catalog.json', 'scraper/events.log', 'sentinel/state.json', LONG, '.circus-backup.json']) assert.ok(byName.has(name), `${name} missing`); assert.equal(text(LONG), 'png-bytes'); for (const name of ['runtime-reader.json', 'scraper/runtime.json', 'sentinel/runtime.json', 'scraper/daemon.lock', 'scraper/supervisor.lock', 'reader.pid', 'db.json.tmp']) assert.ok(!byName.has(name), `${name} must be excluded`); assert.ok(byName.get('scraper/').type === '5'); // The persisted state records the result but never a pending marker inside the archive. assert.equal(JSON.parse(text('backup.json')).pending, undefined); assert.equal(backup.publicState().last.file, result.file); assert.equal(backup.publicState().archives.count, 1); } finally { f.cleanup(); } }); test('an already paused queue stays paused and is not resumed', async () => { const f = fixture(); try { const scraper = fakeScraper({ paused: true }); const result = await service(f, { scraper }).run('schedule'); assert.equal(result.error, undefined); assert.deepEqual(scraper.calls, []); assert.equal(result.queueWasPaused, true); assert.equal(scraper.paused, true); } finally { f.cleanup(); } }); test('a scraper that never becomes idle aborts the backup, leaves no archive and resumes the queue', async () => { const f = fixture(); try { const scraper = fakeScraper({ busyPolls: Infinity }); const result = await service(f, { scraper, idleTimeoutMs: 40 }).run('manual'); assert.match(result.error, /did not become idle/); assert.deepEqual(scraper.calls, ['pause', 'resume']); assert.deepEqual(fs.readdirSync(f.target), []); } finally { f.cleanup(); } }); test('an unreachable scraper does not block the backup and is reported', async () => { const f = fixture(); try { const scraper = { async state() { throw new Error('connect ECONNREFUSED'); }, async control() { throw new Error('unused'); } }; const result = await service(f, { scraper }).run('manual'); assert.equal(result.error, undefined); assert.match(result.warnings[0], /unreachable/); const queue = (await readArchive(path.join(f.target, result.file), { contents: true })).find(e => e.name === 'scraper/queue.json'); assert.equal(JSON.parse(queue.data).paused, true); } finally { f.cleanup(); } }); test('a failed resume is surfaced as a warning', async () => { const f = fixture(); try { const result = await service(f, { scraper: fakeScraper({ failResume: true }) }).run('manual'); assert.equal(result.error, undefined); assert.match(result.warnings.join(' '), /could not be resumed/); } finally { f.cleanup(); } }); test('retention keeps the newest archives and never touches other files', async () => { const f = fixture(); try { const old = ['circus-20260101-030000.tar.gz', 'circus-20260102-030000.tar.gz', 'circus-20260103-030000.tar.gz']; const foreign = ['circus-20250101-0300.tar.zst', 'notes.txt', 'circus-20250101-030000.tar.gz.bak']; for (const name of [...old, ...foreign]) fs.writeFileSync(path.join(f.target, name), 'x'); fs.mkdirSync(path.join(f.target, 'circus-20250101-030000.tar.gz-dir')); fs.writeFileSync(path.join(f.target, 'circus-20250101-030000.tar.gz.part'), 'stale'); const backup = service(f); backup.updateSettings({ retention: { mode: 'count', value: 2 } }); const result = await backup.run('manual'); assert.equal(result.error, undefined); assert.equal(result.deleted, 2); assert.deepEqual(fs.readdirSync(f.target).sort(), [...foreign, 'circus-20250101-030000.tar.gz-dir', old[2], result.file].sort()); // Days mode works from the timestamp in the name. backup.updateSettings({ retention: { mode: 'days', value: 1 } }); const second = await backup.run('manual'); assert.ok(!fs.existsSync(path.join(f.target, old[2]))); assert.ok(fs.existsSync(path.join(f.target, second.file))); for (const name of foreign) assert.ok(fs.existsSync(path.join(f.target, name))); } finally { f.cleanup(); } }); test('backups are off without BACKUP, and a missing folder is reported, never created', async () => { const f = fixture(); try { const off = service(f, { backupRoot: '' }); await assert.rejects(off.run('manual'), /Backups are off/); assert.equal(off.publicState().configured, false); const missing = path.join(f.dir, 'not-mounted'); const result = await service(f, { backupRoot: missing }).run('manual'); assert.match(result.error, /not usable/); assert.ok(!fs.existsSync(missing)); } finally { f.cleanup(); } }); test('the writability probe leaves nothing behind in the backup folder', async () => { const f = fixture(); try { const backup = service(f); await backup.refreshTarget(); assert.equal(backup.publicState().targetError, null); assert.deepEqual(fs.readdirSync(f.target).filter(name => name.startsWith('.circus-backup-probe')), []); } finally { f.cleanup(); } }); test('only one run at a time', async () => { const f = fixture(); try { const backup = service(f, { scraper: fakeScraper({ busyPolls: 5 }) }); const first = backup.run('manual'); await assert.rejects(backup.run('manual'), /already running/); assert.equal((await first).error, undefined); } finally { f.cleanup(); } }); test('the schedule runs once per slot, catches up a recent miss and skips stale slots', async () => { const f = fixture(); try { let now = new Date(2026, 9, 22, 2, 0).getTime(); const backup = service(f, { now: () => now }); backup.updateSettings({ enabled: true, time: '03:30' }); assert.equal(backup.state.nextAt, new Date(2026, 9, 22, 3, 30).getTime()); assert.equal(await backup.tick(), null); now = new Date(2026, 9, 22, 9, 0).getTime(); // reader was down at 03:30 const result = await backup.tick(); assert.equal(result.trigger, 'schedule'); assert.equal(result.error, undefined); assert.equal(backup.state.nextAt, new Date(2026, 9, 23, 3, 30).getTime()); assert.equal(await backup.tick(), null); now = new Date(2026, 9, 30, 9, 0).getTime(); // e.g. an old backup.json was restored assert.equal(await backup.tick(), null); assert.equal(backup.state.nextAt, new Date(2026, 9, 31, 3, 30).getTime()); assert.equal(fs.readdirSync(f.target).length, 1); } finally { f.cleanup(); } }); test('a run interrupted by a restart resumes the queue it paused', async () => { const f = fixture(); try { fs.writeFileSync(path.join(f.data, 'backup.json'), JSON.stringify({ version: 1, pending: { startedAt: 1, trigger: 'schedule', pausedByUs: true } })); const scraper = fakeScraper({ paused: true }); const backup = service(f, { scraper }); await backup.recoverInterrupted(); assert.deepEqual(scraper.calls, ['resume']); assert.match(backup.state.last.error, /Interrupted/); assert.equal(JSON.parse(fs.readFileSync(path.join(f.data, 'backup.json'), 'utf8')).pending, null); } finally { f.cleanup(); } }); test('system tar extracts the archive', { skip: spawnSync('tar', ['--version']).error ? 'no tar binary' : false }, async () => { const f = fixture(); try { const result = await service(f).run('manual'); const out = path.join(f.dir, 'restored'); fs.mkdirSync(out); const tar = spawnSync('tar', ['-xzf', path.join(f.target, result.file), '-C', out], { encoding: 'utf8' }); assert.equal(tar.status, 0, tar.stderr); assert.equal(fs.readFileSync(path.join(out, LONG), 'utf8'), 'png-bytes'); assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'scraper/queue.json'), 'utf8')).paused, true); assert.ok(!fs.existsSync(path.join(out, 'scraper/daemon.lock'))); } finally { f.cleanup(); } }); async function port() { const server = net.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const result = server.address().port; await new Promise(resolve => server.close(resolve)); return result; } test('reader and scraper: admin backup API pauses and resumes the real queue', { timeout: 30000 }, async () => { const f = fixture(), children = [], output = []; try { const library = path.join(f.dir, 'library'); fs.mkdirSync(library); fs.writeFileSync(path.join(f.data, 'scraper', 'queue.json'), JSON.stringify({ paused: false, queue: [], settings: { autoUpdateEnabled: false } })); for (const name of ['runtime.json', 'daemon.lock', 'supervisor.lock']) fs.rmSync(path.join(f.data, 'scraper', name), { force: true }); for (const name of ['db.json', 'catalog.json', 'library-index.json', 'scan-cache.json']) fs.rmSync(path.join(f.data, name)); const [rp, sp] = await Promise.all([port(), port()]); const base = `http://127.0.0.1:${rp}`, token = 'backup-test-only'; const env = { ...process.env, DATA: f.data, LIBRARY: library, SCRAPER_DATA: path.join(f.data, 'scraper'), BACKUP: f.target, PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1', SCRAPER_URL: `http://127.0.0.1:${sp}`, SENTINEL_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', GREATEST_COVER_WARM: '0' }; for (const script of ['server.js', 'scraper/daemon.js']) { const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child); for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString())); } for (const url of [base, `http://127.0.0.1:${sp}`]) { let ready = false; for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); } assert.ok(ready, output.join('')); } const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'admin', password: 'local-test-password' }) }); const cookie = signup.headers.get('set-cookie').split(';')[0]; const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) }); assert.equal((await request('/api/admin/backup', { command: 'settings', settings: { time: '25:00' } })).status, 400); const saved = await (await request('/api/admin/backup', { command: 'settings', settings: { enabled: true, time: '04:00', retention: { mode: 'count', value: 3 } } })).json(); assert.equal(saved.settings.retention.value, 3); assert.ok(saved.nextAt > Date.now()); assert.equal((await request('/api/admin/backup', { command: 'run' })).status, 200); let state; for (let n = 0; n < 100; n++) { state = await (await request('/api/admin/backup')).json(); if (state.last && !state.running) break; await delay(100); } assert.equal(state.last.error, undefined, state.last.error); assert.equal(state.last.queueWasPaused, false); assert.equal(state.last.queueResumed, true); const scraperState = await (await request('/api/admin/scraper')).json(); assert.equal(scraperState.paused, false); const entries = await readArchive(path.join(f.target, state.last.file), { contents: true }); const db = JSON.parse(entries.find(e => e.name === 'db.json').data); assert.ok(db.users.admin); assert.equal(JSON.parse(entries.find(e => e.name === 'scraper/queue.json').data).paused, true); assert.ok((await (await request('/api/admin/health')).json()).backup.last.file); } finally { await Promise.all(children.map(child => new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }))); f.cleanup(); } });