Files
MangaCircus/test/discovery-server.test.js
T
MaashkinoozheandClaude Opus 5.5 cd6a30a76b Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)
- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:43:31 +01:00

319 lines
17 KiB
JavaScript

'use strict';
// BL-0051: discovery harvest routes, per-user refresh and nightly state, against a fixture AniList server.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), http = require('http'), crypto = require('crypto');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
const FIXTURES = path.join(__dirname, 'fixtures', 'anilist');
const fixture = name => JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// The anchor title carries no tags or staff, so a run stays at title + recs + fresh (the client allows 25 a minute).
function fixtureBodies() {
const title = fixture('title');
Object.assign(title.data.Media, { genres: [], tags: [], staff: { edges: [] } });
const recs = fixture('recs');
recs.data.Media.recommendations.nodes[0].rating = 500;
return { title, recs, creator: fixture('creator'), tagtop: fixture('tagtop'), fresh: fixture('fresh') };
}
async function startAniList({ delayMs = 0 } = {}) {
const bodies = fixtureBodies(), seen = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => { raw += chunk; });
req.on('end', async () => {
const query = String(JSON.parse(raw || '{}').query || '');
const kind = query.includes('recommendations') ? 'recs' : query.includes('Staff(') ? 'creator'
: /genre_in|tag_in/.test(query) ? 'tagtop' : query.includes('startDate_greater') ? 'fresh' : 'title';
seen.push(kind);
if (delayMs) await sleep(delayMs);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(bodies[kind]));
});
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return { url: `http://127.0.0.1:${server.address().port}/`, seen, close: () => new Promise(resolve => server.close(resolve)) };
}
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;
}
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 USERS = { boss: { admin: true, discovery: false }, alice: { discovery: true }, bob: { discovery: true }, carol: { discovery: false } };
// A disposable reader with ready-made accounts and sessions; nightly harvests are off unless the store says otherwise.
async function startReader({ anilist, discovery = {}, mangaMeta = null, scraperUrl = 'http://127.0.0.1:9' } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-server-'));
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(path.join(library, 'Example Manga'), { recursive: true });
fs.writeFileSync(path.join(library, 'Example Manga', 'notes.txt'), 'not a chapter');
if (mangaMeta) {
const chapter = path.join(library, mangaMeta.title, 'v01', 'c0001');
fs.mkdirSync(chapter, { recursive: true });
fs.writeFileSync(path.join(chapter, '0001.png'), new Canvas(20, 30, [60, 90, 140]).png());
fs.writeFileSync(path.join(library, mangaMeta.title, 'manga.json'), JSON.stringify(mangaMeta));
}
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true });
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ queue: [] }));
const users = {}, sessions = {}, cookies = {};
for (const [name, spec] of Object.entries(USERS)) {
users[name] = { name, hash: hashPw(`${name}-password`), admin: !!spec.admin, created: Date.now(),
settings: { discoveryEnabled: spec.discovery } };
const token = crypto.randomBytes(32).toString('base64url');
sessions[sha(token)] = { u: name, t: Date.now() };
cookies[name] = `sid=${token}`;
}
fs.writeFileSync(path.join(data, 'db.json'), JSON.stringify({ users, sessions,
discoveryFeedback: { alice: { 'anilist:101': 'interested' } } }));
fs.writeFileSync(path.join(data, 'discovery.json'), JSON.stringify({ schema: 1, revision: 0, candidates: {}, feeds: {},
lastRun: { status: 'never' }, harvest: null, settings: { harvestEnabled: false, harvestAt: '03:30' },
nightly: { lastDate: null }, ...discovery }));
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: scraperUrl, SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
DISCOVERY_ANILIST_URL: anilist ? anilist.url : 'http://127.0.0.1:9/', DISCOVERY_HARVEST_AT: '' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
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 call = async (who, method, url, body) => {
const response = await fetch(base + url, { method, headers: { cookie: cookies[who], ...(body ? { 'content-type': 'application/json' } : {}) },
body: body === undefined ? undefined : JSON.stringify(body) });
return { status: response.status, body: await response.json().catch(() => null) };
};
return {
root, library, data, output,
get: (who, url) => call(who, 'GET', url),
post: (who, url, body = {}) => call(who, 'POST', url, body),
async stop() {
await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
},
};
}
async function until(check, what, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
let last;
while (Date.now() < deadline) {
last = await check();
if (last) return last;
await sleep(100);
}
assert.fail(`Timed out waiting for ${what}`);
}
const idle = (reader, who) => until(async () => {
const { body } = await reader.get(who, '/api/discovery');
return body.refresh.state === 'idle' ? body : null;
}, `${who}'s refresh to finish`);
const harvestDone = reader => until(async () => {
const { body } = await reader.get('boss', '/api/admin/discovery');
return !body.currentHarvest && body.harvest && body.harvest.status !== 'running' ? body : null;
}, 'the harvest to finish');
function snapshot(dir) {
const out = {};
const walk = rel => {
for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
const child = path.join(rel, entry.name), stat = fs.statSync(path.join(dir, child));
out[child] = entry.isDirectory() ? 'dir' : `${stat.size}:${stat.mtimeMs}`;
if (entry.isDirectory()) walk(child);
}
};
walk('');
return out;
}
test('refresh requires opt-in, queues, runs and rescores only the caller', { timeout: 60000 }, async () => {
const anilist = await startAniList();
const reader = await startReader({ anilist });
try {
assert.equal((await reader.post('boss', '/api/admin/discovery/run')).status, 200);
await until(async () => (await reader.get('boss', '/api/admin/discovery')).body.currentRun?.status === 'success', 'the first run');
const bobBefore = (await reader.get('bob', '/api/discovery')).body;
assert.ok(bobBefore.generatedAt);
assert.deepEqual(bobBefore.refresh, { state: 'idle' });
const refused = await reader.post('carol', '/api/discovery/refresh');
assert.equal(refused.status, 403);
const accepted = await reader.post('alice', '/api/discovery/refresh');
assert.equal(accepted.status, 200, JSON.stringify(accepted.body));
assert.match(accepted.body.harvestId, /^[0-9a-f]{16}$/);
assert.equal(accepted.body.position, 0);
const alice = await idle(reader, 'alice');
assert.ok(alice.items.some(item => item.candidateId === 'anilist:201'), JSON.stringify(alice.items.map(i => i.candidateId)));
const rec = alice.items.find(item => item.candidateId === 'anilist:201');
assert.ok(rec.reasons.some(reason => /recommend/.test(reason)));
assert.ok(rec.candidate.fetchedAt);
assert.deepEqual(anilist.seen.sort(), ['fresh', 'recs', 'title']);
const bobAfter = (await reader.get('bob', '/api/discovery')).body;
assert.equal(bobAfter.generatedAt, bobBefore.generatedAt);
assert.deepEqual(bobAfter.items.map(i => i.candidateId), bobBefore.items.map(i => i.candidateId));
const admin = (await reader.get('boss', '/api/admin/discovery')).body;
assert.equal(admin.harvest.kind, 'user');
assert.equal(admin.harvest.user, 'alice');
assert.equal(admin.harvest.status, 'success');
assert.equal(admin.harvest.requests, 3);
assert.equal(admin.harvest.budget, 60);
assert.ok(admin.cache.files >= 3 && admin.cache.bytes > 0, JSON.stringify(admin.cache));
assert.ok(fs.existsSync(path.join(reader.data, 'discovery-cache')));
} finally { await reader.stop(); await anilist.close(); }
});
test('second refresh within an hour is 429 with nextAllowedAt', { timeout: 60000 }, async () => {
const anilist = await startAniList({ delayMs: 400 });
const reader = await startReader({ anilist });
try {
assert.equal((await reader.post('bob', '/api/discovery/refresh')).status, 200);
const duplicate = await reader.post('bob', '/api/discovery/refresh');
assert.equal(duplicate.status, 409);
const state = await idle(reader, 'bob');
assert.ok(Date.parse(state.refresh.nextAllowedAt) > Date.now() + 50 * 60000);
const cooling = await reader.post('bob', '/api/discovery/refresh');
assert.equal(cooling.status, 429);
assert.equal(cooling.body.nextAllowedAt, state.refresh.nextAllowedAt);
assert.equal(typeof cooling.body.error, 'string');
} finally { await reader.stop(); await anilist.close(); }
});
test('admin harvest is admin-only and single-flight', { timeout: 60000 }, async () => {
const anilist = await startAniList({ delayMs: 500 });
const reader = await startReader({ anilist });
try {
assert.equal((await reader.post('alice', '/api/admin/discovery/harvest')).status, 403);
const first = await reader.post('boss', '/api/admin/discovery/harvest');
assert.equal(first.status, 200, JSON.stringify(first.body));
assert.equal(first.body.position, 0);
assert.equal((await reader.post('boss', '/api/admin/discovery/harvest')).status, 409);
const running = (await reader.get('boss', '/api/admin/discovery')).body;
assert.equal(running.currentHarvest.id, first.body.harvestId);
assert.equal(running.currentHarvest.kind, 'admin');
const done = await harvestDone(reader);
assert.equal(done.harvest.kind, 'admin');
assert.equal(done.harvest.budget, 300);
// Afterwards every opted-in user was rescored.
for (const who of ['alice', 'bob']) assert.ok((await reader.get(who, '/api/discovery')).body.generatedAt, who);
} finally { await reader.stop(); await anilist.close(); }
});
// A scraper stand-in. The rescore asks it for the queue after the harvest's final store patch and
// right before publishing, so onState can break discovery.json at exactly that point.
async function startScraper(onState) {
const server = http.createServer((req, res) => {
if (req.url.startsWith('/api/state')) onState();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ queue: [] }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(resolve => server.close(resolve)) };
}
test('a failed rescore after a harvest reaches the admin as lastError', { timeout: 60000 }, async () => {
const anilist = await startAniList();
let reader, armed = false, saved = null;
const scraper = await startScraper(() => {
if (!armed) return;
armed = false;
const file = path.join(reader.data, 'discovery.json');
saved = fs.readFileSync(file, 'utf8');
fs.writeFileSync(file, '{broken'); // the publish now refuses to overwrite it
});
// A malformed manga.json (tags not a list, relations an object) must not be what breaks scoring.
reader = await startReader({ anilist, scraperUrl: scraper.url,
mangaMeta: { title: 'Broken Tags', tags: 5, 'x-anilist': { id: 101, relations: {} } } });
try {
await until(async () => (await reader.get('boss', '/api/library')).body?.mangas?.length === 1, 'the library scan');
armed = true;
const first = await reader.post('boss', '/api/admin/discovery/harvest');
assert.equal(first.status, 200);
await until(async () => saved && reader.output.join('').includes(`${first.body.harvestId} follow-up failed`), 'the failed rescore');
fs.writeFileSync(path.join(reader.data, 'discovery.json'), saved);
const admin = await until(async () => {
const { body } = await reader.get('boss', '/api/admin/discovery');
return body.lastError ? body : null;
}, 'the rescore error');
assert.match(admin.lastError, /^Re-scoring after harvest [0-9a-f]{16} failed: Discovery store unavailable/);
assert.equal(admin.currentRun, null);
assert.equal(admin.harvest.kind, 'admin');
// The next harvest's successful rescore clears the stale error.
const second = await reader.post('boss', '/api/admin/discovery/harvest');
assert.equal(second.status, 200, JSON.stringify(second.body));
const cleared = await until(async () => {
const { body } = await reader.get('boss', '/api/admin/discovery');
return body.harvest?.id === second.body.harvestId && !body.currentHarvest && body.lastError === null ? body : null;
}, 'the error to clear');
assert.ok(cleared.candidateCount > 0);
} finally { await reader.stop(); await anilist.close(); await scraper.close(); }
});
test('settings validation', { timeout: 30000 }, async () => {
const reader = await startReader();
try {
const before = (await reader.get('boss', '/api/admin/discovery')).body;
assert.deepEqual(before.settings, { harvestEnabled: false, harvestAt: '03:30' });
assert.equal(before.nextNightlyAt, null);
assert.equal((await reader.post('alice', '/api/admin/discovery/settings', { harvestEnabled: true, harvestAt: '04:00' })).status, 403);
for (const body of [{ harvestEnabled: true, harvestAt: '25:00' }, { harvestEnabled: true, harvestAt: '4:00' },
{ harvestEnabled: 'yes', harvestAt: '04:00' }, { harvestEnabled: true }, { harvestEnabled: true, harvestAt: '04:00', extra: 1 }]) {
const response = await reader.post('boss', '/api/admin/discovery/settings', body);
assert.equal(response.status, 400, JSON.stringify(body));
}
const saved = await reader.post('boss', '/api/admin/discovery/settings', { harvestEnabled: true, harvestAt: '23:59' });
assert.equal(saved.status, 200);
const after = (await reader.get('boss', '/api/admin/discovery')).body;
assert.deepEqual(after.settings, { harvestEnabled: true, harvestAt: '23:59' });
const next = new Date(after.nextNightlyAt);
// The next slot today at 23:59, or "due now" when the test happens to run inside that minute.
const atSlot = next.getHours() === 23 && next.getMinutes() === 59 && next.getTime() > Date.now() - 120000;
assert.ok(atSlot || Math.abs(next.getTime() - Date.now()) < 120000, after.nextNightlyAt);
} finally { await reader.stop(); }
});
test('interrupted harvest is marked on startup', { timeout: 30000 }, async () => {
const reader = await startReader({ discovery: { harvest: { id: 'abc', kind: 'nightly', user: null, status: 'running',
startedAt: '2026-10-24T03:30:00.000Z', requests: 12, budget: 300 } } });
try {
const { body } = await reader.get('boss', '/api/admin/discovery');
assert.equal(body.harvest.status, 'interrupted');
assert.equal(body.harvest.id, 'abc');
assert.equal(body.harvest.requests, 12);
assert.ok(Date.parse(body.harvest.finishedAt));
assert.equal(body.currentHarvest, null);
} finally { await reader.stop(); }
});
test('harvest never touches scraper queue or library', { timeout: 60000 }, async () => {
const anilist = await startAniList();
const reader = await startReader({ anilist });
try {
const libraryBefore = snapshot(reader.library), scraperBefore = snapshot(path.join(reader.data, 'scraper'));
assert.equal((await reader.post('alice', '/api/discovery/refresh')).status, 200);
await idle(reader, 'alice');
assert.equal((await reader.post('boss', '/api/admin/discovery/harvest')).status, 200);
await harvestDone(reader);
assert.deepEqual(snapshot(reader.library), libraryBefore);
assert.deepEqual(snapshot(path.join(reader.data, 'scraper')), scraperBefore);
} finally { await reader.stop(); await anilist.close(); }
});