Files
MangaCircus/test/greatest-cover-route.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

76 lines
4.2 KiB
JavaScript

'use strict';
// BL-0053: Greatest covers are served from the reader's own copy, with a redirect until one exists.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), crypto = require('crypto');
const { spawn } = require('child_process');
const { loadCatalog } = require('../greatest');
const { GreatestCovers } = require('../greatest/covers');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const hashPw = pw => { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); };
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
async function freePort() {
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));
return port;
}
test('Greatest covers come from the local copy, else redirect to the catalog URL', async t => {
const entries = loadCatalog().entries.filter(entry => entry.cover);
const [cached, missing] = entries;
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-cover-'));
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(library, { recursive: true });
fs.mkdirSync(path.join(data, 'greatest-covers'), { recursive: true });
const version = new GreatestCovers(path.join(data, 'greatest-covers')).version(cached.cover);
fs.writeFileSync(path.join(data, 'greatest-covers', `${version}.png`), PNG);
const token = crypto.randomBytes(32).toString('base64url');
fs.writeFileSync(path.join(data, 'db.json'), JSON.stringify({
users: { reader: { name: 'reader', hash: hashPw('reader-password'), admin: false, created: Date.now(), settings: {} } },
sessions: { [sha(token)]: { u: 'reader', t: Date.now() } } }));
const port = await freePort(), output = [];
const 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: '', BACKUP: '',
GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
t.after(async () => {
await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
});
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 sleep(50);
}
assert.ok(ready, output.join(''));
const get = (url, cookie = `sid=${token}`) => fetch(base + '/' + url, { headers: cookie ? { cookie } : {}, redirect: 'manual' });
const listing = await (await get('api/greatest')).json();
const coverOf = id => listing.entries.find(entry => entry.id === id).cover;
assert.equal(coverOf(cached.id), `api/greatest-cover?id=${encodeURIComponent(cached.id)}&v=${version}`);
assert.match(coverOf(missing.id), /^api\/greatest-cover\?id=.+&v=[0-9a-f]{16}$/);
const hit = await get(coverOf(cached.id));
assert.equal(hit.status, 200);
assert.equal(hit.headers.get('content-type'), 'image/png');
assert.equal(hit.headers.get('cache-control'), 'private, max-age=31536000, immutable');
assert.deepEqual(Buffer.from(await hit.arrayBuffer()), PNG);
const miss = await get(coverOf(missing.id));
assert.equal(miss.status, 302);
assert.equal(miss.headers.get('location'), missing.cover);
assert.equal(miss.headers.get('cache-control'), 'no-store');
assert.equal((await get(coverOf(cached.id), null)).status, 401);
assert.equal((await get('api/greatest-cover?id=not-in-the-catalog')).status, 404);
assert.equal((await get('api/greatest-cover?id=../../db.json')).status, 404);
});