Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
const test = require('node:test'), assert = require('node:assert/strict'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
const { Catalog, normalize, collectionViews } = require('../catalog');
|
||||
const input = () => ({ collections: [{ id: 'franchise', title: 'Franchise', members: [{ mangaId: 'a', role: 'main', readingOrder: 1 }, { mangaId: 'b', role: 'sequel', readingOrder: 2 }] }], relations: [{ from: 'a', to: 'b', type: 'sequel', state: 'confirmed', evidence: [] }] });
|
||||
test('collections persist without changing manga identities and reject stale edits', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-catalog-'));
|
||||
try {
|
||||
const c = new Catalog(dir); c.update({ ...input(), revision: 0 }, new Set(['a', 'b']), 'admin');
|
||||
assert.equal(new Catalog(dir).state.revision, 1);
|
||||
assert.throws(() => c.update({ ...input(), revision: 0 }, new Set(['a', 'b']), 'admin'), { code: 409 });
|
||||
assert.deepEqual(c.state.collections[0].members.map(m => m.mangaId), ['a', 'b']);
|
||||
assert.throws(() => normalize({ ...input(), collections: [...input().collections, { id: 'other', title: 'Other', members: [{ mangaId: 'a' }] }] }, new Set(['a', 'b'])), /already belongs/);
|
||||
assert.throws(() => normalize({ ...input(), relations: [{ from: 'a', to: 'a', type: 'sequel' }] }, new Set(['a', 'b'])), /Invalid relation/);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('collections aggregate progress once and preserve absent members', () => {
|
||||
const normalized = normalize(input(), new Set(['a', 'b']));
|
||||
const views = collectionViews(normalized, [{ id: 'a', title: 'A', chapters: 3, read: 2, unread: 1, tags: ['Fantasy'], status: 'completed', reading: { activeMs: 30 } }, { id: 'b', title: 'B', chapters: 2, read: 1, unread: 1, tags: ['Fantasy'], status: 'ongoing', reading: { activeMs: 20 } }]);
|
||||
assert.equal(views[0].chapters, 5); assert.equal(views[0].read, 3); assert.equal(views[0].reading.activeMs, 50);
|
||||
assert.equal(views[0].status, 'ongoing'); assert.deepEqual(views[0].tags, ['Fantasy']);
|
||||
assert.equal(normalize(normalized, new Set(['a']), normalized).collections[0].members.length, 2);
|
||||
});
|
||||
test('metadata suggestions retain manual and rejected decisions', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-suggestions-'));
|
||||
try {
|
||||
const c = new Catalog(dir);
|
||||
const mangas = [{ id: 'a', meta: { 'x-anilist': { id: 10, relations: [{ id: 11, type: 'SEQUEL' }] } } }, { id: 'b', meta: { 'x-anilist': { id: 11 } } }];
|
||||
const relations = c.suggest(mangas); assert.equal(relations.length, 1); assert.equal(relations[0].state, 'suggested');
|
||||
c.update({ revision: 0, collections: [], relations: [{ ...relations[0], state: 'rejected' }] }, new Set(['a', 'b']), 'admin');
|
||||
assert.equal(c.suggest(mangas).length, 1); assert.equal(c.suggest(mangas)[0].state, 'rejected');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
const crypto = require('node:crypto');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../public/collections.js'), 'utf8').split('(async () => {')[0];
|
||||
test('Collection IDs use the native secure-context API when available', () => {
|
||||
const context = { crypto: { randomUUID: () => 'native-uuid' } };
|
||||
vm.runInNewContext(source, context);
|
||||
assert.equal(context.collectionId(), 'native-uuid');
|
||||
});
|
||||
test('Collection IDs work on HTTP without randomUUID and remain unique valid UUIDs', () => {
|
||||
const context = { crypto: { getRandomValues: values => crypto.randomFillSync(values) } };
|
||||
vm.runInNewContext(source, context);
|
||||
const ids = Array.from({ length: 200 }, () => context.collectionId());
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
for (const id of ids) assert.match(id, /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
applyPulse, chapterInsight, compactRanges, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||
} = require('../reading-analytics');
|
||||
|
||||
const pulse = (session, seq, extra = {}) => ({
|
||||
session, seq, activeMs: 0, loadingMs: 0,
|
||||
inputMs: { mouse: 0, touch: 0, keyboard: 0 }, seen: [],
|
||||
fastBursts: 0, rejectedPageViews: 0, maxPps: 0,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('page ranges remain compact and deterministic', () => {
|
||||
assert.deepEqual(compactRanges([8, 2, 3, 3, 4, 9, 12]), [[2, 4], [8, 9], [12, 12]]);
|
||||
});
|
||||
|
||||
test('reading pulses are idempotent and server-clamped', () => {
|
||||
const record = emptyRecord(), options = { version: 'source-a', pageCount: 4, eligible: [0, 1, 2, 3], now: 10000 };
|
||||
applyPulse(record, pulse('session-one', 1, { activeMs: 9800, inputMs: { touch: 9800 } }), options);
|
||||
assert.equal(record.totalActiveMs, 9800);
|
||||
assert.equal(record.sessions, 1);
|
||||
assert.equal(record.inputMs.touch, 9800);
|
||||
|
||||
const duplicate = applyPulse(record, pulse('session-one', 1, { activeMs: 9800 }), { ...options, now: 20000 });
|
||||
assert.equal(duplicate.duplicate, true);
|
||||
assert.equal(record.totalActiveMs, 9800);
|
||||
|
||||
applyPulse(record, pulse('session-one', 2, { activeMs: 50000, loadingMs: 50000, inputMs: { mouse: 50000 } }), { ...options, now: 21000 });
|
||||
assert.equal(record.totalActiveMs + record.loadingMs, 9800 + 12000);
|
||||
assert.ok(record.inputMs.mouse <= 6000);
|
||||
});
|
||||
|
||||
test('eligible coverage completes a pass and a reread starts a fresh pass', () => {
|
||||
const version = { pages: [{ t: ['cover'] }, { t: [] }, { t: ['extra'] }, { t: [] }] };
|
||||
const eligible = eligiblePages(version, { skipCovers: true, skipExtras: true });
|
||||
assert.deepEqual(eligible, [1, 3]);
|
||||
|
||||
const record = emptyRecord();
|
||||
applyPulse(record, pulse('first-pass', 1, { activeMs: 1000, inputMs: { keyboard: 1000 }, seen: [0, 1, 2, 3] }), {
|
||||
version: 'source-a', pageCount: 4, eligible, now: 1000,
|
||||
});
|
||||
assert.equal(record.completedPasses, 1);
|
||||
assert.equal(record.firstCompleteMs, 1000);
|
||||
assert.deepEqual(record.currentPass.seenRanges, [[1, 1], [3, 3]]);
|
||||
|
||||
applyPulse(record, pulse('second-pass', 1), { version: 'source-a', pageCount: 4, eligible, now: 2000 });
|
||||
const summary = publicSummary(record, 'source-a', eligible);
|
||||
assert.equal(summary.currentPass.complete, false);
|
||||
assert.equal(summary.currentPass.seenPages, 0);
|
||||
assert.equal(summary.totalActiveMs, 1000);
|
||||
});
|
||||
|
||||
test('switching source versions starts separate page coverage without losing lifetime time', () => {
|
||||
const record = emptyRecord();
|
||||
applyPulse(record, pulse('version-test', 1, { activeMs: 800, seen: [0] }), {
|
||||
version: 'source-a', pageCount: 2, eligible: [0, 1], now: 1000,
|
||||
});
|
||||
applyPulse(record, pulse('version-test', 2, { activeMs: 500, seen: [2] }), {
|
||||
version: 'source-b', pageCount: 3, eligible: [0, 1, 2], now: 2000,
|
||||
});
|
||||
const summary = publicSummary(record, 'source-b', [0, 1, 2]);
|
||||
assert.equal(summary.totalActiveMs, 1300);
|
||||
assert.deepEqual(summary.currentPass.seenRanges, [[2, 2]]);
|
||||
});
|
||||
|
||||
test('chapter and manga insights distinguish untimed, partial, complete and reread records', () => {
|
||||
assert.equal(chapterInsight(null), null);
|
||||
const records = {
|
||||
first: { ...emptyRecord(), totalActiveMs: 60000, updatedAt: 100, currentPass: { activeMs: 60000 } },
|
||||
second: { ...emptyRecord(), totalActiveMs: 540000, lastCompleteMs: 180000, firstCompleteMs: 210000, completedPasses: 2, updatedAt: 300, currentPass: { activeMs: 180000, completedAt: 300 } },
|
||||
third: { ...emptyRecord(), totalActiveMs: 300000, lastCompleteMs: 300000, firstCompleteMs: 300000, completedPasses: 1, updatedAt: 200, currentPass: { activeMs: 300000, completedAt: 200 } },
|
||||
};
|
||||
assert.deepEqual(chapterInsight(records.first), {
|
||||
totalActiveMs: 60000, firstCompleteMs: null, lastCompleteMs: null, completedPasses: 0,
|
||||
currentPassMs: 60000, currentPassComplete: false, updatedAt: 100,
|
||||
});
|
||||
assert.deepEqual(mangaInsight(records, ['first', 'second', 'third', 'missing']), {
|
||||
activeMs: 900000,
|
||||
timedChapters: 3,
|
||||
completedChapters: 2,
|
||||
completedPasses: 3,
|
||||
rereads: 1,
|
||||
typicalChapterMs: 240000,
|
||||
lastChapterId: 'second',
|
||||
updatedAt: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('library insights select the most-read and most-recent manga', () => {
|
||||
const summary = libraryInsight([
|
||||
{ id: 'a', reading: { activeMs: 1000, timedChapters: 1, completedPasses: 1, rereads: 0, updatedAt: 100, lastChapterId: '1/1' } },
|
||||
{ id: 'b', reading: { activeMs: 3000, timedChapters: 2, completedPasses: 4, rereads: 2, updatedAt: 90, lastChapterId: '1/2' } },
|
||||
{ id: 'empty', reading: { activeMs: 0, timedChapters: 0, completedPasses: 0, rereads: 0, updatedAt: 0 } },
|
||||
]);
|
||||
assert.deepEqual(summary, {
|
||||
activeMs: 4000, timedChapters: 3, completedPasses: 5, rereads: 2, timedMangas: 2,
|
||||
mostTimeMangaId: 'b', lastMangaId: 'a', lastChapterId: '1/1', updatedAt: 100,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const { applyVisit, emptyRecord, chapterInsight, publicSummary } = require('../reading-analytics');
|
||||
const { exposureTracker, stateFor, IDLE_MS } = require('../public/reading-clock-core');
|
||||
const pages = n => Array.from({ length: n }, (_, i) => i);
|
||||
const config = { version: 'webtoon', pageCount: 20, eligible: pages(20), now: 10000 };
|
||||
const visit = (extra = {}) => ({ session: 'reading-visit', seq: 1, elapsedMs: 10000, activeMs: 9000, seen: [], ...extra });
|
||||
|
||||
test('59% is a draft; 60% credits the entire visit and final duration keeps growing', () => {
|
||||
const r = emptyRecord(), cfg = { ...config, pageCount: 100, eligible: pages(100) };
|
||||
applyVisit(r, visit({ seen: pages(59) }), cfg);
|
||||
assert.equal(r.totalActiveMs, 0); assert.equal(chapterInsight(r), null);
|
||||
applyVisit(r, visit({ seq: 2, elapsedMs: 12000, activeMs: 11000, seen: pages(60) }), cfg);
|
||||
assert.equal(r.totalActiveMs, 11000); assert.equal(r.completedPasses, 1);
|
||||
applyVisit(r, visit({ seq: 3, elapsedMs: 18000, activeMs: 16000, seen: pages(65), final: true }), cfg);
|
||||
assert.equal(r.totalActiveMs, 16000); assert.equal(r.firstCompleteMs, 16000); assert.equal(r.lastCompleteMs, 16000);
|
||||
assert.equal(r.currentPass.closed, true);
|
||||
});
|
||||
test('an exit snapshot overtaking a pulse counts once and survives a JSON reload', () => {
|
||||
const r = emptyRecord();
|
||||
applyVisit(r, visit({ seq: 3, seen: pages(12), final: true }), config);
|
||||
const restored = JSON.parse(JSON.stringify(r));
|
||||
assert.equal(applyVisit(restored, visit({ seq: 1, activeMs: 0 }), config).duplicate, true);
|
||||
assert.equal(applyVisit(restored, visit({ seq: 3, seen: pages(12), final: true }), config).duplicate, true);
|
||||
applyVisit(restored, visit({ seq: 4, seen: pages(13), activeMs: 10000, elapsedMs: 11000 }), config);
|
||||
assert.equal(restored.totalActiveMs, 10000); assert.equal(restored.completedPasses, 1);
|
||||
});
|
||||
test('delayed delivery does not truncate an honest minute to twelve seconds', () => {
|
||||
const r = emptyRecord();
|
||||
applyVisit(r, visit({ elapsedMs: 65000, activeMs: 60000, inputMs: { touch: 60000 }, seen: pages(12) }), config);
|
||||
assert.equal(r.totalActiveMs, 60000); assert.equal(r.inputMs.touch, 60000);
|
||||
});
|
||||
test('one-picture lookup does not borrow prior coverage or change saved statistics', () => {
|
||||
const r = emptyRecord();
|
||||
applyVisit(r, visit({ seen: pages(12), final: true }), config);
|
||||
applyVisit(r, visit({ session: 'lookup-visit', elapsedMs: 60000, activeMs: 59000, seen: [15], final: true }), { ...config, now: 70000 });
|
||||
assert.equal(r.totalActiveMs, 9000); assert.equal(r.completedPasses, 1);
|
||||
assert.equal(chapterInsight(r).updatedAt, 10000);
|
||||
});
|
||||
test('late finals update their own visit without replacing a newer visit', () => {
|
||||
const r = emptyRecord();
|
||||
applyVisit(r, visit({ seen: pages(12) }), config);
|
||||
applyVisit(r, visit({ session: 'new-visit', seen: pages(12), activeMs: 4000 }), { ...config, now: 20000 });
|
||||
applyVisit(r, visit({ seq: 2, seen: pages(14), elapsedMs: 15000, activeMs: 13000, final: true }), { ...config, now: 21000 });
|
||||
assert.equal(r.currentPass.visit, 'new-visit'); assert.equal(r.firstCompleteMs, 13000);
|
||||
assert.equal(r.lastCompleteMs, 4000); assert.equal(r.totalActiveMs, 17000);
|
||||
});
|
||||
test('legacy time and source IDs are retained; covers/extras use the eligible denominator', () => {
|
||||
const r = { ...emptyRecord(), totalActiveMs: 5000, completedPasses: 1, firstCompleteMs: 5000, lastCompleteMs: 5000, updatedAt: 500 };
|
||||
const cfg = { ...config, eligible: [1, 3, 5, 7, 9] };
|
||||
applyVisit(r, visit({ seen: [0, 1, 3, 5, 19], activeMs: 2000 }), cfg);
|
||||
assert.equal(r.totalActiveMs, 7000); assert.equal(r.firstCompleteMs, 5000);
|
||||
assert.equal(publicSummary(r, 'webtoon', cfg.eligible).currentPass.requiredPages, 3);
|
||||
});
|
||||
test('fast webtoon slices above five pages per second are credited without a ceiling', () => {
|
||||
const t = exposureTracker();
|
||||
for (let i = 0; i < 10; i++) t.update([{ index: i, loaded: true }], i * 120, true);
|
||||
t.update([], 1200, true);
|
||||
assert.deepEqual([...t.seen], pages(10));
|
||||
});
|
||||
test('jumping over pages to dwell on one image never invents skipped coverage', () => {
|
||||
const t = exposureTracker();
|
||||
t.update([{ index: 0, loaded: true }], 0, true);
|
||||
t.update([{ index: 19, loaded: true }], 50, true);
|
||||
t.update([{ index: 19, loaded: true }], 60000, true);
|
||||
assert.deepEqual([...t.seen], [19]);
|
||||
});
|
||||
test('unloaded and hidden pages cannot qualify; mixed loading does not pause readable pages', () => {
|
||||
assert.equal(IDLE_MS, 30000, 'The user-selected inactivity allowance is 30 seconds');
|
||||
const t = exposureTracker();
|
||||
t.update([{ index: 0, loaded: false }], 0, true);
|
||||
t.update([{ index: 0, loaded: false }], 1000, true);
|
||||
assert.equal(t.seen.size, 0);
|
||||
const view = { pages: [{ index: 0, loaded: true }, { index: 1, loaded: false }] };
|
||||
const s = { visible: true, focused: true, lastInputAt: 0, now: 5000, owned: true, view };
|
||||
assert.equal(stateFor(s), 'reading');
|
||||
assert.equal(stateFor({ ...s, visible: false }), 'hidden');
|
||||
assert.equal(stateFor({ ...s, focused: false }), 'unfocused');
|
||||
assert.equal(stateFor({ ...s, now: IDLE_MS }), 'idle');
|
||||
assert.equal(stateFor({ ...s, now: IDLE_MS - 1 }), 'reading');
|
||||
t.update(view.pages, 1000, false); t.update(view.pages, 100000, false);
|
||||
assert.equal(t.seen.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
||||
const { spawn } = require('child_process');
|
||||
const { Canvas } = require('../tools/png');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function port() {
|
||||
const server = net.createServer();
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const result = server.address().port; await new Promise(resolve => server.close(resolve)); return result;
|
||||
}
|
||||
test('three services integrate collections, auth, custom covers and private health without source traffic', { timeout: 30000 }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-services-'));
|
||||
const children = [], output = [];
|
||||
try {
|
||||
const data = path.join(dir, 'data'), library = path.join(dir, 'library');
|
||||
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true });
|
||||
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||
const png = new Canvas(32, 48, [10, 90, 130]).png();
|
||||
for (const name of ['Main Work', 'Sequel']) {
|
||||
const folder = path.join(library, name, 'v01', 'c001'); fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(path.join(folder, '001.png'), png);
|
||||
fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
||||
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, tags: ['Fantasy'], status: 'ongoing' }));
|
||||
}
|
||||
const [rp, sp, wp] = await Promise.all([port(), port(), port()]);
|
||||
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`, sentinel = `http://127.0.0.1:${wp}`;
|
||||
const token = 'integration-test-only';
|
||||
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'),
|
||||
PORT: String(rp), SCRAPER_PORT: String(sp), SENTINEL_PORT: String(wp), SCRAPER_HOST: '127.0.0.1', SENTINEL_HOST: '127.0.0.1',
|
||||
READER_URL: base, SCRAPER_URL: scraper, SENTINEL_URL: sentinel, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
|
||||
for (const script of ['server.js', 'scraper/daemon.js', 'sentinel/daemon.js']) {
|
||||
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
|
||||
}
|
||||
for (const url of [base, scraper, sentinel]) {
|
||||
let ready = false;
|
||||
for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); }
|
||||
assert.ok(ready, output.join(''));
|
||||
}
|
||||
assert.equal((await fetch(scraper + '/api/state')).status, 401);
|
||||
assert.equal((await fetch(sentinel + '/api/state')).status, 401);
|
||||
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'tester', password: 'local-test-password' }) });
|
||||
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
|
||||
const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
||||
let inventory;
|
||||
for (let n = 0; n < 50; n++) { inventory = await (await request('/api/library')).json(); if (inventory.mangas.length === 2) break; await delay(100); }
|
||||
assert.equal(inventory.mangas.length, 2);
|
||||
const ids = inventory.mangas.map(m => m.id);
|
||||
const catalog = { revision: 0, collections: [{ id: 'demo', title: 'Demo collection', members: ids.map((mangaId, index) => ({ mangaId, readingOrder: index + 1, role: index ? 'sequel' : 'main' })), coverMode: 'mosaic' }], relations: [{ from: ids[0], to: ids[1], type: 'sequel', state: 'confirmed' }] };
|
||||
let response = await request('/api/admin/catalog', catalog); assert.equal(response.status, 200); const saved = await response.json();
|
||||
response = await request('/api/admin/catalog', catalog); assert.equal(response.status, 409); assert.match((await response.json()).error, /reload/);
|
||||
const view = await (await request('/api/collection?id=demo')).json(); assert.equal(view.members.length, 2); assert.equal(view.chapters, 2); assert.equal(view.relations.length, 1);
|
||||
let revision = saved.revision;
|
||||
for (let n = 0; n < 2; n++) {
|
||||
const cover = await (await request('/api/admin/collection-cover', { id: 'demo', revision, base64: png.toString('base64') })).json(); revision = cover.revision;
|
||||
const image = await request('/api/collection-cover?id=demo'); assert.equal(image.status, 200); assert.deepEqual(Buffer.from(await image.arrayBuffer()), png);
|
||||
}
|
||||
const health = await (await request('/api/admin/health')).json(); assert.ok(health.reader); assert.ok(health.sentinel);
|
||||
assert.equal((await request('/api/admin/scraper')).status, 200);
|
||||
// Exercise the actual authenticated visit route and cross-chapter lease races.
|
||||
assert.equal((await request('/api/settings', { readingClock: true })).status, 200);
|
||||
const readerChapter = async m => {
|
||||
const detail = await (await request('/api/manga?m=' + encodeURIComponent(m))).json();
|
||||
return (await request('/api/chapter?m=' + encodeURIComponent(m) + '&c=' + encodeURIComponent(detail.chapters[0].id))).json();
|
||||
};
|
||||
const first = await readerChapter(ids[0]), second = await readerChapter(ids[1]);
|
||||
const visit = (d, session, seq, extra = {}) => ({ m: d.manga.id, c: d.chapter.id, v: d.version, session, seq, elapsedMs: 2000, activeMs: 0, seen: [], ...extra });
|
||||
const send = async b => (await request('/api/reading/visit', b)).json();
|
||||
await send(visit(first, 'first-visit', 1));
|
||||
let result = await send(visit(first, 'first-visit', 3, { activeMs: 1000, seen: [0], final: true, release: true }));
|
||||
assert.equal(result.reading.totalActiveMs, 1000);
|
||||
assert.equal((await send(visit(second, 'second-visit', 1))).accepted, true);
|
||||
// The stale in-flight pulse arrives after exit and after the next reader claims.
|
||||
assert.equal((await send(visit(first, 'first-visit', 2))).duplicate, true);
|
||||
result = await send(visit(first, 'first-visit', 4, { activeMs: 1500, seen: [0], final: true, release: true }));
|
||||
assert.equal(result.accepted, true); assert.equal(result.reading.totalActiveMs, 1500);
|
||||
// A beacon arriving before its first claim is also retained, without stealing
|
||||
// the current reader's lease.
|
||||
assert.equal((await send(visit(first, 'beacon-first', 2, { activeMs: 500, seen: [0], final: true, release: true }))).accepted, true);
|
||||
assert.equal((await send(visit(second, 'third-visit', 1))).accepted, false);
|
||||
await send(visit(second, 'second-visit', 2, { release: true }));
|
||||
assert.equal((await fetch(base + '/api/reading/visit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(visit(first, 'unauth-visit', 1)) })).status, 401);
|
||||
await request('/api/settings', { readingClock: false });
|
||||
assert.equal((await request('/api/reading/visit', visit(first, 'disabled-visit', 1))).status, 403);
|
||||
const unauth = await fetch(base + '/api/admin/catalog'); assert.equal(unauth.status, 401);
|
||||
await request('/api/signup', { name: 'reader', password: 'local-test-password' });
|
||||
const login = await fetch(base + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'reader', password: 'local-test-password' }) });
|
||||
// Sign-up is closed after first-run; the admin catalog remains protected.
|
||||
assert.equal(login.status, 403);
|
||||
} finally {
|
||||
await Promise.all(children.map(child => new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); })));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SETTINGS_SECTIONS, SCRAPER_TABS, settingsRoute } = require('../public/settings-nav');
|
||||
|
||||
test('settings organize unique routes into User and Admin, with scraper-local tabs', () => {
|
||||
assert.equal(new Set(SETTINGS_SECTIONS.map(s => s.id)).size, SETTINGS_SECTIONS.length);
|
||||
assert.deepEqual([...new Set(SETTINGS_SECTIONS.map(s => s.group))], ['User', 'Admin']);
|
||||
assert.ok(SETTINGS_SECTIONS.every(s => s.id.split('/').length === 2));
|
||||
assert.deepEqual(SCRAPER_TABS, ['activity', 'queue', 'updates', 'sources']);
|
||||
for (const section of SETTINGS_SECTIONS) assert.equal(settingsRoute('#' + section.id, true).id, section.id);
|
||||
});
|
||||
|
||||
test('settings deep links default safely and never route ordinary readers into admin', () => {
|
||||
assert.deepEqual(settingsRoute('', false), { id: 'user/reading', tab: 'activity' });
|
||||
assert.equal(settingsRoute('#not/a/section', true).id, 'user/reading');
|
||||
assert.equal(settingsRoute('#admin/users', false).id, 'user/reading');
|
||||
assert.equal(settingsRoute('#admin/scraper/hosters', false).id, 'user/reading');
|
||||
assert.equal(settingsRoute('#user/notifications', false).id, 'user/notifications');
|
||||
assert.deepEqual(settingsRoute('#admin/scraper/hosters', true), { id: 'admin/scraper', tab: 'sources' });
|
||||
assert.deepEqual(settingsRoute('#admin/scraper/sources', true), { id: 'admin/scraper', tab: 'sources' });
|
||||
assert.equal(settingsRoute('#admin/scraper/unknown', true).tab, 'activity');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
// Isolated browser QA. Never reads production user data or contacts image sources.
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), { spawn } = require('child_process');
|
||||
const { Canvas } = require('../tools/png');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-ui-')), data = path.join(dir, 'data'), library = path.join(dir, 'library');
|
||||
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true });
|
||||
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||
for (const [index, name] of ['Moon Garden', 'Moon Garden Before the Rain', 'Iron Harbor'].entries()) {
|
||||
const folder = path.join(library, name, 'v01', 'c001 The Beginning'); fs.mkdirSync(folder, { recursive: true });
|
||||
const png = new Canvas(180, 270, [50 + index * 40, 60, 120]).png();
|
||||
fs.writeFileSync(path.join(folder, '001.png'), png); fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
||||
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
||||
}
|
||||
const env = { ...process.env, DATA: data, LIBRARY: library, PORT: '8180', SCRAPER_PORT: '8191', SENTINEL_PORT: '8192',
|
||||
READER_URL: 'http://127.0.0.1:8180', SCRAPER_URL: 'http://127.0.0.1:8191', SENTINEL_URL: 'http://127.0.0.1:8192',
|
||||
SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'), COOKIE_SECURE: '0', SCAN_INTERVAL: '0', SCRAPER_TOKEN: 'fixture', SCRAPER_TOKEN_FILE: '' };
|
||||
const child = spawn(process.execPath, ['tools/start-all.js'], { cwd: path.resolve(__dirname, '..'), env, stdio: 'inherit', windowsHide: true });
|
||||
console.log('Isolated UI fixture at http://127.0.0.1:8180 · first account is test admin');
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill('SIGTERM'));
|
||||
child.once('exit', code => { fs.rmSync(dir, { recursive: true, force: true }); process.exit(code || 0); });
|
||||
Reference in new issue
Block a user