The reader owns a scheduled backup into BACKUP (Compose: BACKUP_PATH mounted at /backup, off when unset). A run pauses the scraper queue through its control API, waits (bounded) until no job is active, writes a verified tar.gz of DATA with db.json from memory and scraper/queue.json stored paused, excludes runtime/lock/pid/temp files, resumes only a queue it paused, then applies count- or day-based retention to its own archive names only. Schedule, retention, "Back up now" and last result live in Settings > Service health. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
16 KiB
JavaScript
277 lines
16 KiB
JavaScript
'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('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' };
|
|
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();
|
|
}
|
|
});
|