Files
MangaCircus/test/backup.test.js
MaashkinoozheandClaude Opus 5.5 e9da27e688 Greatest: serve catalog covers from a local copy (BL-0053)
The page hotlinked ~100 AniList CDN covers per load and some failed at random.
The reader now downloads each catalog cover once (sequential, ~1/s, stops on
401/403/429, images up to 2 MB) into data/greatest-covers/ and serves it from
/api/greatest-cover with a year-long browser cache; until a copy exists the
route redirects to the catalog URL. The page also accepts the server's
relative cover URLs, so owned titles show their library cover again.
GREATEST_COVER_WARM=0 turns the downloads off (tests, offline installs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:38:55 +01:00

287 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('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();
}
});