'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 } = require('child_process'); const { Canvas } = require('../tools/png'); const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('../library'); const image = new Canvas(20, 40, [20, 40, 80]).png(); function chapter(root, manga, number) { const dir = path.join(root, manga, 'v01', `c${String(number).padStart(4, '0')}`); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, '0001.png'), image); fs.writeFileSync(path.join(dir, 'chapter.json'), JSON.stringify({ title: `Part ${number}` })); } test('saved index restores chapter maps and book references without rereading images', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-index-')); try { chapter(root, 'Alpha', 1); chapter(root, 'Beta', 1); const first = await scanLibrary(root); const stamps = await libraryStamps(root); const restored = restoreIndex(JSON.parse(JSON.stringify(snapshotIndex(first.index)))); assert.equal(restored.byId.get('Alpha').chapterById.get('1/1').title, 'Part 1'); assert.strictEqual(restored.byId.get('Alpha').books[0].chapters[0], restored.byId.get('Alpha').chapters[0]); const same = await scanChangedLibrary(root, restored, first.cache, stamps); assert.deepEqual(same.changed, []); assert.strictEqual(same.index, restored); assert.throws(() => restoreIndex([{ id: 'Broken', books: [], chapters: [{ id: 'x', versions: [{}] }] }]), /Invalid chapter/); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('shallow refresh replaces only a published manga and removes deleted cache entries', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-changed-')); try { chapter(root, 'Alpha', 1); chapter(root, 'Beta', 1); const first = await scanLibrary(root), stamps = await libraryStamps(root); const beta = first.index.byId.get('Beta'); chapter(root, 'Alpha', 2); const next = await scanChangedLibrary(root, first.index, first.cache, stamps); assert.deepEqual(next.changed, ['Alpha']); assert.equal(next.index.byId.get('Alpha').chapters.length, 2); assert.strictEqual(next.index.byId.get('Beta'), beta); assert.equal(Object.keys(next.cache).length, 3); fs.rmSync(path.join(root, 'Alpha'), { recursive: true }); const removed = await scanChangedLibrary(root, next.index, next.cache, next.stamps); assert.deepEqual(removed.changed, ['Alpha']); assert.equal(removed.index.byId.has('Alpha'), false); assert.equal(Object.keys(removed.cache).length, 1); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('in-place chapter metadata edits require a full reconciliation', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-deep-')); try { chapter(root, 'Alpha', 1); const first = await scanLibrary(root), stamps = await libraryStamps(root); fs.writeFileSync(path.join(root, 'Alpha', 'v01', 'c0001', 'chapter.json'), JSON.stringify({ title: 'Revised Part 1' })); const shallow = await scanChangedLibrary(root, first.index, first.cache, stamps); assert.deepEqual(shallow.changed, []); assert.equal((await scanLibrary(root, first.cache)).index.byId.get('Alpha').chapters[0].title, 'Revised Part 1'); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('reader serves a saved index immediately after restart and verifies it in background', { timeout: 15000 }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-warm-start-')); let child; try { const library = path.join(root, 'library'), data = path.join(root, 'data'); fs.mkdirSync(data); chapter(library, 'Alpha', 1); const scanned = await scanLibrary(library), stamps = await libraryStamps(library); fs.writeFileSync(path.join(data, 'library-index.json'), JSON.stringify({ version: 1, root: library, at: Date.now(), mangas: snapshotIndex(scanned.index), cache: scanned.cache, stamps })); const listener = net.createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve)); const output = []; child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '' } }); child.stdout.on('data', data => output.push(String(data))); child.stderr.on('data', data => output.push(String(data))); const base = `http://127.0.0.1:${port}`; let ready = false; for (let n = 0; n < 100 && !ready; n++) { ready = await fetch(base + '/health').then(r => r.ok).catch(() => false); if (!ready) await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(ready, output.join('')); const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'fixture', password: 'fixture-password' }) }); assert.equal(signup.status, 200); const response = await fetch(base + '/api/library', { headers: { cookie: signup.headers.get('set-cookie').split(';')[0] } }); assert.equal(response.status, 200); assert.equal((await response.json()).mangas[0].title, 'Alpha'); assert.match(output.join(''), /restored 1 manga from saved index/); } finally { if (child) await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }); fs.rmSync(root, { recursive: true, force: true }); } });