Files
MangaCircus/test/library-index.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

103 lines
5.9 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 } = 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: '', GREATEST_COVER_WARM: '0' } });
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 });
}
});