'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { ScraperService, validateSettings } = require('../service'); function fixture(scanLibrary) { const service = new ScraperService({ scanLibrary, adapters: [] }); service.state = { version: 4, paused: true, status: 'paused', queue: [], settings: validateSettings({}), currentIds: {} }; service.recentEvents = async () => []; return service; } const inventory = title => ({ mangas: title ? [{ id: title, title, status: 'ongoing', autoUpdate: true, chapters: [], sources: [] }] : [] }); test('cold status returns controls immediately and simultaneous probes share one slow scan', { timeout: 1000 }, async () => { let finish, scans = 0; const service = fixture(() => { scans++; return new Promise(resolve => { finish = resolve; }); }); const states = await Promise.all(Array.from({ length: 8 }, () => service.publicState())); assert.equal(scans, 1); for (const state of states) { assert.equal(state.paused, true); assert.equal(state.settings.sourceRates.mangadex.minMs, 1000); assert.deepEqual(state.library, []); assert.equal(state.libraryStatus.ready, false); assert.equal(state.libraryStatus.refreshing, true); } finish(inventory('Demo')); await service.inventoryRefresh; const ready = await service.publicState(); assert.equal(ready.library[0].title, 'Demo'); assert.equal(ready.libraryStatus.ready, true); assert.equal(ready.libraryStatus.stale, false); assert.equal(scans, 1); }); test('status keeps previous summary but authoritative callers await scans and reject invalidated results', { timeout: 1000 }, async () => { const completions = []; const service = fixture(() => new Promise(resolve => completions.push(resolve))); const first = service.libraryInventory(true); await Promise.resolve(); completions[0](inventory('Old')); await first; service.invalidateInventory(); const fresh = service.libraryInventory(true); await Promise.resolve(); let finished = false; fresh.then(() => { finished = true; }); let state = await service.publicState(); assert.equal(state.library[0].title, 'Old'); assert.equal(state.libraryStatus.stale, true); assert.equal(state.libraryStatus.refreshing, true); assert.equal(finished, false); // Publication during an in-flight scan requires another authoritative scan. service.invalidateInventory(); const scan = service.inventoryRefresh; completions[1](inventory('Obsolete')); await scan; await Promise.resolve(); assert.equal(completions.length, 3); assert.equal(finished, false); completions[2](inventory('Published')); assert.equal((await fresh).mangas[0].title, 'Published'); state = await service.publicState(); assert.equal(state.library[0].title, 'Published'); assert.equal(state.libraryStatus.stale, false); }); test('background inventory failures are visible without breaking controls or launching a poll storm', { timeout: 1000 }, async () => { let scans = 0; const service = fixture(async () => { scans++; throw new Error('Scan failed https://test.example/a?token=private'); }); await service.publicState(); await assert.rejects(service.inventoryRefresh, /Scan failed/); for (let n = 0; n < 3; n++) { const state = await service.publicState(); assert.equal(state.paused, true); assert.match(state.libraryStatus.error, /Scan failed/); assert.ok(!state.libraryStatus.error.includes('private')); } assert.equal(scans, 1); await assert.rejects(service.libraryInventory(true), /Scan failed/); assert.equal(scans, 2); }); test('status polling walks the library again only after a change or after 15 minutes', async () => { let scans = 0; const service = fixture(async () => { scans++; return inventory('Demo'); }); await service.libraryInventory(true); service.inventoryCachedAt = Date.now() - 10 * 60000; service.inventoryAttemptedAt = 0; for (let n = 0; n < 5; n++) await service.publicState(); assert.equal(scans, 1); assert.deepEqual(service.revision(), { publishedRevision: 0, status: 'paused', paused: true }); assert.equal(scans, 1); service.invalidateInventory(); await service.publicState(); await service.inventoryRefresh; assert.equal(scans, 2); service.inventoryCachedAt = Date.now() - 16 * 60000; service.inventoryAttemptedAt = 0; await service.publicState(); await service.inventoryRefresh; assert.equal(scans, 3); }); test('dashboard polling reuses a recent snapshot while decisions still force a fresh scan', async () => { let scans = 0; const service = fixture(async () => { scans++; return inventory('Demo'); }); await service.libraryInventory(true); service.inventoryCachedAt = Date.now() - 30000; for (let n = 0; n < 5; n++) assert.equal((await service.publicState()).library[0].title, 'Demo'); assert.equal(scans, 1); await service.libraryInventory(true); assert.equal(scans, 2); });