'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { DiscoveryCache } = require('../discovery/cache'); const { AniListStop, AniListSkip } = require('../discovery/anilist'); const { BUDGETS, REFRESH_COOLDOWN_MS, MAX_CONSECUTIVE_FAILURES, runHarvest, shouldRunNightly, Harvester, } = require('../discovery/harvest'); const tempCache = (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'discovery-harvest-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return new DiscoveryCache(dir); }; const record = (id, { tags = [], authors = [] } = {}) => ({ anilistId: id, title: `T${id}`, tags, authors }); // Fake client: answers from fixture maps, logs every call, and throws scripted errors by call number. function fakeClient({ titles = {}, errors = {} } = {}) { const calls = []; const answer = (name, key, value) => { calls.push(`${name}:${key}`); const error = errors[calls.length]; if (error) throw error; return value; }; return { calls, async title(id) { return answer('title', id, id in titles ? titles[id] : record(id)); }, async recs(id) { return answer('recs', id, [{ anilistId: id * 100 }]); }, async creator(staffId) { return answer('creator', staffId, [{ anilistId: staffId * 10 }]); }, async tagTop({ name, kind }) { return answer('tagTop', `${kind}:${name}`, [{ anilistId: 1 }]); }, async fresh() { return answer('fresh', 'global', [{ anilistId: 2 }]); }, }; } const anchor = (anilistId, weight = 1) => ({ anilistId, weight, source: 'owned', title: null }); test('constants', () => { assert.deepEqual(BUDGETS, { nightly: 300, admin: 300, user: 60 }); assert.equal(REFRESH_COOLDOWN_MS, 3600000); assert.equal(MAX_CONSECUTIVE_FAILURES, 5); }); test('plan order and dedupe', async (t) => { const cache = tempCache(t); const titles = { 1: record(1, { tags: [{ name: 'Drama', kind: 'genre', rank: 100, isAdult: false }], authors: [{ staffId: 9, name: 'A', role: 'Story' }] }), 2: record(2, { tags: [{ name: 'Drama', kind: 'genre', rank: 100, isAdult: false }] }), 3: record(3, { tags: [{ name: 'Gore', kind: 'tag', rank: 80, isAdult: false }], authors: [{ staffId: 8, name: 'B', role: 'Art' }] }), }; const client = fakeClient({ titles }); const targets = [ { user: 'ann', anchors: [anchor(1, 5), anchor(2, 1)], mature: false }, { user: 'bob', anchors: [anchor(1, 3), anchor(3, 1)], mature: false }, ]; const result = await runHarvest({ kind: 'admin', targets, cache, client, budget: 300 }); assert.deepEqual(client.calls, [ 'title:1', 'title:2', 'title:3', 'recs:1', 'recs:2', 'recs:3', 'creator:9', 'tagTop:genre:Drama', 'tagTop:tag:Gore', 'fresh:global', ]); assert.deepEqual(result, { requests: 10, budget: 300, status: 'success', stopReason: null }); assert.equal(cache.get('title', 1).data.anilistId, 1); assert.deepEqual(cache.get('recs', 2).data, [{ anilistId: 200 }]); assert.deepEqual(cache.get('creator', 9).data, [{ anilistId: 90 }]); assert.deepEqual(cache.get('tagTop', 'genre:Drama').data, [{ anilistId: 1 }]); assert.deepEqual(cache.get('fresh', 'global').data, [{ anilistId: 2 }]); }); test('non-manga titles are counted but not cached', async (t) => { const cache = tempCache(t); const client = fakeClient({ titles: { 4: null } }); const result = await runHarvest({ kind: 'user', targets: [{ user: 'ann', anchors: [anchor(4, 5)], mature: false }], cache, client, budget: 60 }); assert.equal(cache.get('title', 4), null); assert.deepEqual(client.calls, ['title:4', 'recs:4', 'fresh:global']); assert.equal(result.requests, 3); }); test('fresh cache entries are skipped', async (t) => { const cache = tempCache(t); cache.put('title', 1, record(1)); cache.put('recs', 1, []); cache.put('fresh', 'global', []); const client = fakeClient(); const result = await runHarvest({ kind: 'user', targets: [{ user: 'ann', anchors: [anchor(1), anchor(2)], mature: false }], cache, client, budget: 60 }); assert.deepEqual(client.calls, ['title:2', 'recs:2']); assert.equal(result.requests, 2); assert.equal(result.stopReason, null); }); test('budget stops with reason budget', async (t) => { const cache = tempCache(t); const client = fakeClient(); const targets = [{ user: 'ann', anchors: [anchor(1), anchor(2), anchor(3)], mature: false }]; const result = await runHarvest({ kind: 'user', targets, cache, client, budget: 3 }); assert.deepEqual(result, { requests: 3, budget: 3, status: 'success', stopReason: 'budget' }); assert.equal(client.calls.length, 3); }); test('429 stops at once', async (t) => { const cache = tempCache(t); const client = fakeClient({ errors: { 2: new AniListStop('rate-limited') } }); const targets = [{ user: 'ann', anchors: [anchor(1), anchor(2), anchor(3)], mature: false }]; const result = await runHarvest({ kind: 'admin', targets, cache, client, budget: 300 }); assert.deepEqual(result, { requests: 2, budget: 300, status: 'stopped', stopReason: 'rate-limited' }); assert.equal(client.calls.length, 2); assert.equal(cache.get('title', 2), null); }); test('network error stops with upstream-errors after 5', async (t) => { const skip = () => new AniListSkip('timeout'); const targets = [{ user: 'ann', anchors: [1, 2, 3, 4, 5, 6, 7, 8].map((id) => anchor(id)), mature: false }]; const client = fakeClient({ errors: { 1: skip(), 2: skip(), 3: skip(), 4: skip(), 5: skip() } }); const result = await runHarvest({ kind: 'admin', targets, cache: tempCache(t), client, budget: 300 }); assert.deepEqual(result, { requests: 5, budget: 300, status: 'stopped', stopReason: 'upstream-errors' }); assert.equal(client.calls.length, 5); // A success in the middle resets the counter, so the run gets past five failures in total. const mixed = fakeClient({ errors: { 1: skip(), 2: skip(), 3: skip(), 4: skip(), 6: skip(), 7: skip(), 8: skip() } }); const reset = await runHarvest({ kind: 'admin', targets, cache: tempCache(t), client: mixed, budget: 300 }); assert.equal(reset.status, 'success'); assert.equal(reset.stopReason, null); assert.ok(mixed.calls.length > 8); }); test('plan for anchorless user only fetches fresh', async (t) => { const client = fakeClient(); const result = await runHarvest({ kind: 'user', targets: [{ user: 'ann', anchors: [], mature: false }], cache: tempCache(t), client, budget: 60 }); assert.deepEqual(client.calls, ['fresh:global']); assert.deepEqual(result, { requests: 1, budget: 60, status: 'success', stopReason: null }); }); test('shouldRunNightly', () => { const at = (h, m) => new Date(2026, 9, 25, h, m); assert.equal(shouldRunNightly({ now: at(3, 29), harvestAt: '03:30', lastNightlyDate: null, enabled: true }), false); assert.equal(shouldRunNightly({ now: at(3, 30), harvestAt: '03:30', lastNightlyDate: null, enabled: true }), true); assert.equal(shouldRunNightly({ now: at(23, 0), harvestAt: '03:30', lastNightlyDate: '2026-10-24', enabled: true }), true); assert.equal(shouldRunNightly({ now: at(3, 30), harvestAt: '03:30', lastNightlyDate: '2026-10-25', enabled: true }), false); assert.equal(shouldRunNightly({ now: at(3, 30), harvestAt: '03:30', lastNightlyDate: null, enabled: false }), false); }); // A run that waits until the test releases it, so queue state can be observed. function gatedHarvester({ onDone = async () => {}, now, load }) { const gates = []; const patches = []; const done = []; const client = { calls: [], async fresh() { this.calls.push('fresh'); await new Promise((resolve, reject) => gates.push({ resolve, reject })); return []; }, }; // Nothing stays cached, so every job makes its one fresh call and waits at the gate. const cache = { isFresh: () => false, get: () => null, put() {} }; const harvester = new Harvester({ cache, client, store: { patch(value) { patches.push(structuredClone(value)); }, ...(load ? { load } : {}) }, targetsFor: (kind, user) => [{ user: user || 'all', anchors: [], mature: false }], onDone: async (job, result) => { done.push({ job, result }); await onDone(job, result); }, now, }); // Lets the waiting request finish, or fail with `error`. const release = async (error) => { for (let i = 0; !gates.length; i++) { if (i > 1000) throw new Error('no harvest request is waiting'); await new Promise(setImmediate); } const gate = gates.shift(); if (error) gate.reject(error); else gate.resolve(); await new Promise(setImmediate); }; return { harvester, patches, done, release, client }; } const settle = async () => { for (let i = 0; i < 20; i++) await new Promise(setImmediate); }; test('harvester single-flight, positions, 409 and 429 cooldown', async (t) => { let clock = Date.parse('2026-10-25T10:00:00Z'); const h = gatedHarvester({ now: () => clock }); const { harvester } = h; assert.deepEqual(harvester.state('ann'), { state: 'idle' }); assert.equal(harvester.current(), null); const first = harvester.request({ kind: 'user', user: 'ann' }); assert.equal(first.position, 0); assert.equal(typeof first.harvestId, 'string'); assert.equal(harvester.state('ann').state, 'running'); assert.deepEqual(harvester.current(), { id: first.harvestId, kind: 'user', user: 'ann', startedAt: '2026-10-25T10:00:00.000Z' }); assert.throws(() => harvester.request({ kind: 'admin' }), (error) => error.status === 409); assert.throws(() => harvester.request({ kind: 'nightly' }), (error) => error.status === 409); assert.throws(() => harvester.request({ kind: 'user', user: 'ann' }), (error) => error.status === 409); const second = harvester.request({ kind: 'user', user: 'bob' }); assert.equal(second.position, 1); assert.deepEqual(harvester.state('bob'), { state: 'queued', position: 1, nextAllowedAt: '2026-10-25T11:00:00.000Z' }); assert.throws(() => harvester.request({ kind: 'user', user: 'bob' }), (error) => error.status === 409); await h.release(); await settle(); assert.equal(harvester.state('bob').state, 'running'); assert.equal(harvester.state('ann').state, 'idle'); clock += 30 * 60000; assert.throws(() => harvester.request({ kind: 'user', user: 'ann' }), (error) => { assert.equal(error.status, 429); assert.equal(error.nextAllowedAt, '2026-10-25T11:00:00.000Z'); return true; }); assert.deepEqual(harvester.state('ann'), { state: 'idle', nextAllowedAt: '2026-10-25T11:00:00.000Z' }); await h.release(); await settle(); assert.equal(harvester.current(), null); // The persisted records: running first, then the final one, for each job in order. assert.deepEqual(h.patches.map((p) => [p.harvest.user, p.harvest.status]), [['ann', 'running'], ['ann', 'success'], ['bob', 'running'], ['bob', 'success']]); assert.deepEqual(h.patches[0].harvest, { id: first.harvestId, kind: 'user', user: 'ann', status: 'running', startedAt: '2026-10-25T10:00:00.000Z', requests: 0, budget: 60 }); assert.equal(h.patches[1].harvest.requests, 1); assert.equal(h.patches[1].harvest.stopReason, null); assert.equal(typeof h.patches[1].harvest.finishedAt, 'string'); assert.deepEqual(h.done.map((d) => d.job.user), ['ann', 'bob']); clock += 30 * 60000; const admin = harvester.request({ kind: 'admin' }); assert.equal(admin.position, 0); const ann = harvester.request({ kind: 'user', user: 'ann' }); assert.equal(ann.position, 1); await h.release(); await h.release(); await settle(); assert.equal(h.patches.at(-4).harvest.budget, 300); }); test('onDone failure does not stall the queue', async (t) => { const errors = []; const original = console.error; console.error = (...args) => errors.push(args); t.after(() => { console.error = original; }); const h = gatedHarvester({ now: () => Date.parse('2026-10-25T10:00:00Z'), onDone: async () => { throw new Error('score failed'); } }); h.harvester.request({ kind: 'nightly' }); h.harvester.request({ kind: 'user', user: 'ann' }); await h.release(); await h.release(); await settle(); assert.deepEqual(h.done.map((d) => d.job.kind), ['nightly', 'user']); assert.equal(h.harvester.current(), null); assert.ok(errors.length >= 2); }); test('AniList stop pauses user refreshes until an admin or nightly run', async () => { let clock = Date.parse('2026-10-25T10:00:00Z'); const h = gatedHarvester({ now: () => clock }); const { harvester, client } = h; harvester.request({ kind: 'user', user: 'ann' }); harvester.request({ kind: 'user', user: 'bob' }); assert.equal(harvester.state('bob').state, 'queued'); // (a) 429 while ann runs: bob's queued refresh is dropped without touching AniList. await h.release(new AniListStop('rate-limited')); await settle(); assert.equal(client.calls.length, 1); assert.equal(harvester.current(), null); assert.deepEqual(harvester.state('bob'), { state: 'idle', blockedReason: 'rate-limited' }); assert.deepEqual(h.patches.map((p) => [p.harvest.user, p.harvest.status, p.harvest.stopReason ?? null]), [['ann', 'running', null], ['ann', 'stopped', 'rate-limited']]); assert.deepEqual(h.done.map((d) => d.job.user), ['ann']); // (b) a new refresh is refused while paused, even for a user outside any cooldown. clock += 2 * REFRESH_COOLDOWN_MS; assert.throws(() => harvester.request({ kind: 'user', user: 'cid' }), (error) => { assert.equal(error.status, 503); assert.equal(error.blockedReason, 'rate-limited'); assert.match(error.message, /AniList paused the harvest: rate-limited/); return true; }); assert.equal(client.calls.length, 1); // (c) an accepted admin run is the allowed retry and lifts the pause. harvester.request({ kind: 'admin' }); assert.equal(harvester.state('cid').blockedReason, undefined); await h.release(); await settle(); assert.equal(client.calls.length, 2); assert.equal(harvester.request({ kind: 'user', user: 'cid' }).position, 0); await h.release(); await settle(); }); test('pause survives a restart through the stored harvest record', () => { const load = () => ({ harvest: { status: 'stopped', stopReason: 'forbidden' } }); const { harvester } = gatedHarvester({ now: () => 0, load }); assert.throws(() => harvester.request({ kind: 'user', user: 'ann' }), (error) => error.status === 503 && error.blockedReason === 'forbidden'); assert.equal(harvester.state('ann').blockedReason, 'forbidden'); const budgetStop = gatedHarvester({ now: () => 0, load: () => ({ harvest: { status: 'stopped', stopReason: 'budget' } }) }); assert.deepEqual(budgetStop.harvester.state('ann'), { state: 'idle' }); const broken = gatedHarvester({ now: () => 0, load: () => { throw new Error('corrupt'); } }); assert.deepEqual(broken.harvester.state('ann'), { state: 'idle' }); }); test('unknown kinds are refused', () => { const { harvester } = gatedHarvester({ now: () => 0 }); for (const kind of ['constructor', 'toString', 'other']) assert.throws(() => harvester.request({ kind }), (error) => error.status === 400); }); test('local fault keeps the real request count and async targets work', async (t) => { const original = console.error; console.error = () => {}; t.after(() => { console.error = original; }); let puts = 0; const cache = { isFresh: () => false, get: () => null, put() { if (++puts === 2) throw new Error('disk full'); } }; const patches = []; const done = []; const harvester = new Harvester({ cache, client: fakeClient(), store: { patch(value) { patches.push(value); } }, targetsFor: async () => [{ user: 'ann', anchors: [anchor(1)], mature: false }], onDone: async (job, result) => { done.push(result); }, }); harvester.request({ kind: 'admin' }); await settle(); assert.deepEqual(patches.at(-1).harvest.status, 'stopped'); assert.equal(patches.at(-1).harvest.stopReason, 'error'); assert.equal(patches.at(-1).harvest.requests, 2); assert.equal(done.length, 1); });