'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 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 = {} } = {}) { 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'); 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: 'http://127.0.0.1:9', 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(); } }); 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(); } });