Files
MangaCircus/test/discovery-cache.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

117 lines
4.5 KiB
JavaScript

'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 DAY = 86400000;
function setup(t, options = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'circus-dcache-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const clock = { t: Date.UTC(2026, 0, 1) };
const cache = new DiscoveryCache(dir, { ...options, now: () => clock.t });
return { dir, clock, cache };
}
test('cache round-trips an entry', (t) => {
const { clock, cache } = setup(t);
cache.put('title', 42, { a: 1 });
const entry = cache.get('title', 42);
assert.deepEqual(entry.data, { a: 1 });
assert.equal(entry.fetchedAt, new Date(clock.t).toISOString());
assert.equal(entry.schema, 1);
assert.equal(entry.kind, 'title');
});
test('freshness follows TTL per kind', (t) => {
const { clock, cache } = setup(t);
cache.put('fresh', 'global', []);
cache.put('title', 1, {});
clock.t += 8 * DAY;
assert.equal(cache.isFresh('fresh', 'global'), false);
assert.equal(cache.isFresh('title', 1), true);
clock.t += 23 * DAY;
assert.equal(cache.isFresh('title', 1), false);
assert.notEqual(cache.get('title', 1), null);
assert.equal(cache.list('title').length, 1);
});
test('prune keeps the newest maxFiles', (t) => {
const { clock, cache } = setup(t, { maxFiles: 3 });
for (let i = 1; i <= 5; i++) { clock.t += 1000; cache.put('title', i, { i }); }
assert.equal(cache.stats().files, 3);
assert.equal(cache.get('title', 1), null);
assert.equal(cache.get('title', 2), null);
assert.notEqual(cache.get('title', 5), null);
});
test('prune removes the oldest entries across kinds', (t) => {
const { clock, cache } = setup(t, { maxFiles: 3 });
const kinds = ['title', 'recs', 'creator', 'fresh', 'tagTop'];
kinds.forEach((kind, i) => { clock.t += 1000; cache.put(kind, i, { i }); });
assert.equal(cache.stats().files, 3);
assert.equal(cache.get('title', 0), null);
assert.equal(cache.get('recs', 1), null);
for (const [i, kind] of kinds.entries()) if (i >= 2) assert.notEqual(cache.get(kind, i), null, kind);
});
test('a write under the cap does not read other entries', (t) => {
const { dir, cache } = setup(t, { maxFiles: 10 });
cache.put('recs', 7, {});
const file = path.join(dir, 'recs', fs.readdirSync(path.join(dir, 'recs'))[0]);
fs.writeFileSync(file, '{broken');
cache.put('title', 1, {});
assert.equal(fs.readFileSync(file, 'utf8'), '{broken');
});
test('over the cap, corrupt and schema-mismatch entries go before valid ones', (t) => {
const { dir, clock, cache } = setup(t, { maxFiles: 2 });
cache.put('recs', 1, {});
cache.put('recs', 2, {});
const [first, second] = fs.readdirSync(path.join(dir, 'recs')).map(name => path.join(dir, 'recs', name));
fs.writeFileSync(first, '{broken');
fs.writeFileSync(second, JSON.stringify({ schema: 2, kind: 'recs', key: 2, fetchedAt: new Date(clock.t).toISOString(), data: {} }));
clock.t += 1000;
cache.put('title', 3, {});
assert.equal(fs.existsSync(first), false);
assert.equal(fs.existsSync(second), false);
assert.notEqual(cache.get('title', 3), null);
});
test('schema-mismatch entry is deleted like a corrupt one', (t) => {
const { dir, clock, cache } = setup(t);
cache.put('recs', 7, {});
const file = path.join(dir, 'recs', fs.readdirSync(path.join(dir, 'recs'))[0]);
fs.writeFileSync(file, JSON.stringify({ schema: 2, kind: 'recs', key: 7, fetchedAt: new Date(clock.t).toISOString(), data: {} }));
assert.equal(cache.get('recs', 7), null);
assert.equal(fs.existsSync(file), false);
});
test('corrupt entry is deleted', (t) => {
const { dir, cache } = setup(t);
cache.put('recs', 7, {});
const file = path.join(dir, 'recs', fs.readdirSync(path.join(dir, 'recs'))[0]);
fs.writeFileSync(file, '{broken');
assert.equal(cache.get('recs', 7), null);
assert.equal(fs.existsSync(file), false);
});
test('odd keys are file-safe', (t) => {
const { dir, cache } = setup(t);
cache.put('tagTop', 'genre:Slice of Life/../x', [1]);
const files = fs.readdirSync(path.join(dir, 'tagTop'));
assert.equal(files.length, 1);
assert.deepEqual(fs.readdirSync(dir), ['tagTop']);
assert.deepEqual(cache.get('tagTop', 'genre:Slice of Life/../x').data, [1]);
});
test('unknown kind throws', (t) => {
const { cache } = setup(t);
assert.throws(() => cache.put('nope', 1, {}), TypeError);
assert.throws(() => cache.get('nope', 1), TypeError);
});