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>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 09:43:31 +01:00
1 parent 613db96355
commit cd6a30a76b
18 files changed
+250 -65

No files matched your search

+42 -9
View File
@@ -50,7 +50,7 @@ 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 } = {}) {
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 });
@@ -80,7 +80,7 @@ async function startReader({ anilist, discovery = {}, mangaMeta = null } = {}) {
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: '',
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)));
@@ -216,22 +216,55 @@ test('admin harvest is admin-only and single-flight', { timeout: 60000 }, async
} 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();
// A manga.json whose tags is not a list makes scoring throw; it stands in for any scoring fault.
const reader = await startReader({ anilist, mangaMeta: { title: 'Broken Tags', tags: 5 } });
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');
assert.equal((await reader.post('boss', '/api/admin/discovery/harvest')).status, 200);
const done = await harvestDone(reader);
assert.equal(done.harvest.kind, 'admin');
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: /);
assert.match(admin.lastError, /^Re-scoring after harvest [0-9a-f]{16} failed: Discovery store unavailable/);
assert.equal(admin.currentRun, null);
} finally { await reader.stop(); await anilist.close(); }
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 () => {