Files
MangaCircus/scraper/test/mangadex-specials.test.js
T

139 lines
11 KiB
JavaScript

'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs/promises'), os = require('node:os'), path = require('node:path');
const { specialKey, parseSpecialFolder } = require('../../chapter-identity');
const { selectChapterReleases, availabilitySummary, discoverMangaDexCatalog, discoverMangaDexChapter } = require('../adapters/mangadex');
const { selectByScope } = require('../lib/scope');
const inventory = require('../lib/library-inventory');
const { publishManifest } = require('../lib/publisher');
const reader = require('../../library');
const { ScraperService } = require('../service');
const ids = ['37d6949a-c7ce-4855-b838-4fd572e87629', '27dd738c-eca2-488d-a791-53ab4b24313b'];
const titleId = '06a37094-b3ba-4f25-bf2b-54c9751e0e91';
const release = (id, chapter = null) => ({ id, attributes: { chapter, pages: 46, title: 'Pilot', externalUrl: null }, relationships: [] });
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function until(predicate) {
const end = Date.now() + 5000;
while (!predicate()) { if (Date.now() > end) throw new Error('Fixture timed out'); await pause(20); }
}
test('unnumbered releases retain source IDs, do not invent chapter zero, and remain distinct', async () => {
const selected = selectChapterReleases(ids.map(id => release(id)), 'Pilot');
assert.equal(selected.length, 2);
assert.deepEqual(new Set(selected.map(r => r.number)), new Set(ids.map(specialKey)));
assert.ok(selected.every(r => r.special && r.label === 'Pilot' && r.sourceNumber === null));
assert.equal(selectByScope(selected, { mode: 'chapter', chapter: '0' }).length, 0);
assert.equal(selectByScope(selected, { mode: 'after', chapter: '1' }).length, 0);
assert.equal(selectByScope(selected, { mode: 'missing' }).length, 2);
assert.equal(selectByScope(selected, { mode: 'ids', ids: [ids[0]] })[0].id, ids[0]);
assert.throws(() => specialKey('../escape'));
assert.equal(parseSpecialFolder(specialKey(ids[0]) + ' Pilot [MangaDex]').src, 'MangaDex');
const chapter = await discoverMangaDexChapter(selected[0], { async get() { return { async json() { return { result: 'ok', baseUrl: 'https://uploads.mangadex.org', chapter: { hash: 'fixture', data: Array.from({ length: 46 }, (_, n) => `${n}.png`) } }; } }; } });
assert.equal(chapter.label, 'Pilot'); assert.equal(chapter.sourceId, selected[0].id);
});
test('availability distinguishes no English, external-only, and partial hosted catalogs', () => {
assert.equal(availabilitySummary({ availableTranslatedLanguages: ['ar'] }, [], [], 0).reason, 'no-english');
const external = availabilitySummary({}, [], [], 8);
assert.equal(external.reason, 'external-only'); assert.match(external.message, /8 English external-link/);
const chapters = selectChapterReleases([release('regular-fixture', '336')]);
const summary = availabilitySummary({ lastChapter: '686' }, [release('regular-fixture', '336')], chapters);
assert.equal(summary.coverage, 'partial'); assert.equal(summary.selectedChapters, 1);
assert.equal(availabilitySummary({ lastChapter: '336' }, [], chapters).coverage, 'partial', 'One late chapter does not cover the earlier 335');
const full = selectChapterReleases(['1', '2', '3'].map((number, index) => release(`full-${index}`, number)));
assert.equal(availabilitySummary({ lastChapter: '3' }, [], full).coverage, 'unknown', 'A plausible count is still not proof of whole-series completeness');
});
test('catalog uses separate external-only feed only when hosted selections are empty', async () => {
const modes = [];
const http = { async get(url) {
const parsed = new URL(url), mode = parsed.searchParams.get('includeExternalUrl');
if (!mode) return { async json() { return { data: { type: 'manga', attributes: { title: { en: 'CLAYMORE' }, lastChapter: '155' } } }; } };
modes.push(mode);
return { async json() { return { result: 'ok', data: [], total: mode === '1' ? 8 : 0 }; } };
} };
const catalog = await discoverMangaDexCatalog(`https://mangadex.org/title/${titleId}`, http);
assert.deepEqual(modes, ['0', '1']); assert.equal(catalog.availability.reason, 'external-only');
modes.length = 0;
http.get = async url => {
const mode = new URL(url).searchParams.get('includeExternalUrl');
if (!mode) return { async json() { return { data: { type: 'manga', attributes: { title: { en: 'Bleach (Pre-Serialization)' } } } }; } };
modes.push(mode); return { async json() { return { result: 'ok', data: ids.map(id => release(id)), total: 2 }; } };
};
const pilot = await discoverMangaDexCatalog(`https://mangadex.org/title/${titleId}`, http);
assert.deepEqual(modes, ['0']); assert.equal(pilot.availability.specialReleases, 2); assert.equal(pilot.chapters[0].label, 'Pilot');
});
test('special publication is atomic, readable, stable across rescans and duplicate-safe', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-special-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
// Real fixture PNG; no upstream image requests.
const { Canvas } = require('../../tools/png');
const page = path.join(root, '.fixture.png'); await fs.writeFile(page, new Canvas(10, 20, [1, 2, 3]).png());
const manifest = { version: 1, series: { title: 'Pilot edition', direction: 'rtl', sources: [{ name: 'MangaDex', url: `https://mangadex.org/title/${titleId}` }] },
chapters: selectChapterReleases(ids.map(id => release(id)), 'Pilot').map(ref => ({ ...ref, source: { name: 'MangaDex', url: ref.url }, pages: [{ path: page }] })) };
const first = await publishManifest(manifest, { libraryRoot: root });
assert.ok(first.results.every(result => result.status === 'published'));
const scanned = (await reader.scanLibrary(root)).index.mangas[0];
assert.equal(scanned.chapters.length, 2); assert.ok(scanned.chapters.every(c => c.label === 'Pilot' && c.num === null && !c.extra));
const stableIds = scanned.chapters.map(c => c.id);
assert.deepEqual((await publishManifest(manifest, { libraryRoot: root })).results.map(r => r.status), ['skipped', 'skipped']);
assert.deepEqual((await reader.scanLibrary(root)).index.mangas[0].chapters.map(c => c.id), stableIds);
const manga = (await inventory.scanLibrary(root)).mangas[0];
assert.equal(inventory.shouldDownload(manga, manifest.chapters[0], 'mangadex'), false);
assert.equal(inventory.isNewerThanLibrary(manga, manifest.chapters[0]), false);
assert.equal(inventory.isNewerThanLibrary(manga, { number: '1' }), true);
assert.equal(reader.parseKey('c0012.5 Existing [Host]').key, '12.5');
assert.equal(reader.parseKey('v01').key, '1');
const regular = { ...manifest, chapters: [{ number: '12.5', title: 'Existing chapter', source: { name: 'Host', url: 'https://host.test/12' }, pages: [{ path: page }] }] };
await publishManifest(regular, { libraryRoot: root });
assert.ok((await reader.scanLibrary(root)).index.mangas[0].chapterById.has('1/12.5'));
});
test('legacy unavailable messages become unverified without retrying or downloading', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-unavailable-')), dataRoot = path.join(root, 'data');
await fs.mkdir(dataRoot); await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({ paused: true,
queue: [{ id: 'old-job', source: 'mangadex', url: `https://mangadex.org/title/${titleId}`, status: 'unavailable', error: 'No downloadable English MangaDex chapters found', failure: { kind: 'unavailable', scope: 'job' } }],
settings: { autoUpdateEnabled: false } }));
const service = await new ScraperService({ libraryRoot: path.join(root, 'library'), dataRoot }).init();
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
assert.equal(service.state.queue[0].status, 'unavailable'); assert.equal(service.active.size, 0);
assert.match(service.state.queue[0].error, /Inspect this source again/);
assert.equal(service.state.queue[0].failure.kind, 'unavailable');
});
test('inspection waits for confirmation, survives restart, and uses one normal source lane', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-inspect-')), dataRoot = path.join(root, 'data');
await fs.mkdir(dataRoot); await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
let running = 0, maxRunning = 0, downloads = 0;
const catalog = { title: 'Canonical Pilot', chapters: ids.map(id => ({ id, number: specialKey(id), special: true })), availability: { selectedChapters: 2, specialReleases: 2, coverage: 'unknown' } };
const adapter = { id: 'fixture', label: 'Fixture', match: url => ({ source: 'fixture', id: url.split('/').pop(), url, title: 'Misleading slug' }),
async discover() { maxRunning = Math.max(maxRunning, ++running); await pause(50); running--; return catalog; },
async run(item, options) { downloads++; await options.onCatalog(catalog); return { publishedCount: 1, selectedCount: 1 }; } };
const options = { libraryRoot: path.join(root, 'library'), dataRoot, adapters: [adapter], adapterFor: url => ({ adapter, info: adapter.match(url) }) };
let service = await new ScraperService(options).init();
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
const item = await service.enqueue({ url: 'https://fixture.test/one', inspect: true });
const other = await service.enqueue({ url: 'https://fixture.test/two', inspect: true });
await assert.rejects(service.control('confirm', item.id), /Inspect the source first/);
await service.control('resume'); await until(() => item.status === 'ready' && other.status === 'ready');
assert.equal(downloads, 0); assert.equal(maxRunning, 1); assert.equal(item.sourceTitle, 'Canonical Pilot');
await service.close(); service = await new ScraperService(options).init();
const restored = service.state.queue.find(q => q.id === item.id);
assert.equal(restored.kind, 'inspection'); assert.equal(restored.status, 'ready');
await service.control('confirm', restored.id); await until(() => restored.status === 'complete');
await until(() => service.active.size === 0);
assert.equal(downloads, 1); assert.equal(restored.selectedCount, 1);
assert.equal(service.state.history.at(-1).availability.specialReleases, 2);
service.state.sourcePaused.fixture = true;
await assert.rejects(service.control('inspect', restored.id), /access block/);
});
test('edition change after confirmation cannot silently download another edition', async () => {
const service = Object.create(ScraperService.prototype);
service.persist = async () => {};
service.state = { sourceLedger: {} };
const item = { title: 'slug', confirmedSourceTitle: 'Pilot edition' };
await assert.rejects(service.recordCatalog(item, { title: 'Main edition', chapters: [] }), /edition title changed/);
});