'use strict'; const test = require('node:test'), assert = require('node:assert/strict'), fs = require('fs').promises, os = require('os'), path = require('path'); const { ScraperService, validateSettings } = require('../service'); const { classifyFailure, safeMessage } = require('../lib/failures'); test('failure classification protects access challenges and distinguishes job failures', () => { for (const status of [401, 403, 429]) assert.equal(classifyFailure(Object.assign(new Error('Host response'), { status })).kind, 'access'); assert.equal(classifyFailure(new Error('No downloadable English MangaDex chapters found')).scope, 'job'); assert.equal(classifyFailure(Object.assign(new Error('Disk full'), { code: 'ENOSPC' })).scope, 'global'); assert.equal(classifyFailure(new Error('HTTP 503')).retryable, true); assert.equal(safeMessage('HTTP 403 https://test.example/a?token=secret'), 'HTTP 403 https://test.example/a'); }); async function fixture(t, errors, options = {}) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-recovery-')); const dataRoot = path.join(dir, 'data'); await fs.mkdir(dataRoot); const counts = {}; const adapter = { id: 'test', label: 'Test', run: async item => { counts[item.id] = (counts[item.id] || 0) + 1; if (errors[item.id]) throw errors[item.id]; return { publishedCount: 0 }; } }; await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({ version: 3, paused: true, settings: validateSettings({ autoUpdateEnabled: false }), queue: Object.keys(errors).concat('healthy').map(id => ({ id, seriesId: id, source: 'test', title: id, url: 'https://test.example/' + id, status: 'queued' })), history: [] })); const service = await new ScraperService({ libraryRoot: path.join(dir, 'library'), dataRoot, adapterFor: url => ({ adapter, info: { id: url.split('/').pop() } }), adapters: [adapter], ...options }).init(); t.after(async () => { await service.close(); await fs.rm(dir, { recursive: true, force: true }); }); await service.control('resume'); return { service, counts }; } async function wait(predicate) { const end = Date.now() + 6000; while (!predicate()) { if (Date.now() > end) throw new Error('Timeout'); await new Promise(r => setTimeout(r, 25)); } } test('unavailable titles and one parser failure do not stop healthy jobs', async t => { const { service } = await fixture(t, { absent: new Error('No downloadable English MangaDex chapters found'), parser: new Error('Catalog markup changed') }); await wait(() => service.state.queue.find(i => i.id === 'healthy').status === 'complete'); assert.equal(service.state.queue.find(i => i.id === 'absent').status, 'unavailable'); assert.equal(service.state.queue.find(i => i.id === 'parser').status, 'quarantined'); assert.equal(service.state.sourcePaused.test, undefined); }); test('transient recovery is bounded and access errors are never retried automatically', async t => { const { service, counts } = await fixture(t, { broken: new Error('HTTP 503') }, { retryDelayMs: 10 }); await wait(() => service.state.sourcePaused.test); assert.equal(counts.broken, 2); assert.equal(service.state.queue[0].status, 'error'); }); test('protection failures pause only the affected lane and keep checkpoints', async t => { const { service, counts } = await fixture(t, { blocked: new Error('HTTP 403') }); await wait(() => service.state.sourcePaused.test); assert.equal(counts.blocked, 1); assert.equal(counts.healthy, undefined); assert.equal(service.state.incidents[0].failure.kind, 'access'); });