The stall watchdog now runs inside the scraper (ScraperService.checkStalls, every 30 s from the work loop): same rule as before, one checkpoint-safe restart, quarantine on the second stall, missing checkpoint is no evidence, and paused time no longer counts. Stalls are recorded as scraper incidents (failure.kind 'stall'). The private /api/recovery endpoint is gone. Dropped with the service: reader/scraper-unreachable incidents (supervisor and Docker handle crashes; the health page shows "Scraper: unreachable"), duplicate job-failure incidents and the unused /metrics endpoint. The stack is now reader + scraper (+ optional peer-gateway). The supervisor test moved to runtime/test (npm run test:runtime stays). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
13 KiB
JavaScript
160 lines
13 KiB
JavaScript
'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] = await Promise.all([port(), port()]);
|
|
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`;
|
|
const token = 'integration-test-only';
|
|
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'),
|
|
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1',
|
|
READER_URL: base, SCRAPER_URL: scraper, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
|
|
for (const script of ['server.js', 'scraper/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]) {
|
|
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);
|
|
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);
|
|
assert.equal((await fetch(base + '/api/greatest')).status, 401);
|
|
const greatest = await (await request('/api/greatest')).json();
|
|
assert.equal(greatest.entries.length, 106);
|
|
const firstPick = greatest.entries[0];
|
|
assert.equal(firstPick.myRating, null);
|
|
assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: 'flame' })).status, 200);
|
|
assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: 'invalid' })).status, 400);
|
|
assert.equal((await request('/api/greatest/rating', { id: 'missing', rating: 'good' })).status, 404);
|
|
const afterRating = (await (await request('/api/greatest')).json()).entries[0];
|
|
assert.equal(afterRating.myRating, 'flame');
|
|
assert.equal(afterRating.votes.flame, 1);
|
|
assert.equal(afterRating.score, 3);
|
|
assert.equal((await request('/api/admin/user', { name: 'another', password: 'local-test-password' })).status, 200);
|
|
const anotherLogin = await fetch(base + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'another', password: 'local-test-password' }) });
|
|
assert.equal(anotherLogin.status, 200);
|
|
const anotherCookie = anotherLogin.headers.get('set-cookie').split(';')[0];
|
|
const anotherRequest = (endpoint, body) => fetch(base + endpoint, { headers: { cookie: anotherCookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
|
assert.equal((await (await request('/api/me')).json()).settings.pageTone, 'original');
|
|
assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia');
|
|
assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia');
|
|
assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original');
|
|
assert.equal((await fetch(base + '/api/discovery')).status, 401);
|
|
assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403);
|
|
assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);
|
|
assert.equal((await anotherRequest('/api/settings', { discoveryEnabled: true })).status, 200);
|
|
const beforeDiscoveryQueue = fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8');
|
|
const firstRun = await request('/api/admin/discovery/run', {});
|
|
assert.equal(firstRun.status, 200);
|
|
const firstRunId = (await firstRun.json()).id;
|
|
let discoveryAdmin;
|
|
for (let n = 0; n < 50; n++) {
|
|
discoveryAdmin = await (await request('/api/admin/discovery')).json();
|
|
if (discoveryAdmin.currentRun?.id === firstRunId && discoveryAdmin.currentRun?.status === 'success') break;
|
|
await delay(100);
|
|
}
|
|
assert.equal(discoveryAdmin.currentRun?.status, 'success');
|
|
const discoveryMine = await (await request('/api/discovery')).json();
|
|
const discoveryOther = await (await anotherRequest('/api/discovery')).json();
|
|
assert.ok(discoveryMine.items.length > 0 && discoveryMine.items.length <= 20);
|
|
assert.ok(discoveryOther.items.length > 0 && discoveryOther.items.length <= 20);
|
|
assert.equal(discoveryMine.items[0].candidate.id, discoveryMine.items[0].candidateId);
|
|
const dismissed = discoveryMine.items[0].candidateId;
|
|
assert.equal((await request('/api/discovery/feedback', { candidateId: dismissed, value: 'dismissed' })).status, 200);
|
|
assert.equal((await anotherRequest('/api/discovery')).status, 200);
|
|
assert.equal((await anotherRequest('/api/discovery/feedback', { candidateId: 'nonexistent', value: 'interested' })).status, 404);
|
|
const secondRun = await request('/api/admin/discovery/run', {});
|
|
assert.equal(secondRun.status, 200);
|
|
const secondRunId = (await secondRun.json()).id;
|
|
for (let n = 0; n < 50; n++) {
|
|
discoveryAdmin = await (await request('/api/admin/discovery')).json();
|
|
if (discoveryAdmin.currentRun?.id === secondRunId && discoveryAdmin.currentRun?.status === 'success') break;
|
|
await delay(100);
|
|
}
|
|
assert.ok(!(await (await request('/api/discovery')).json()).items.some(item => item.candidateId === dismissed));
|
|
assert.equal(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8'), beforeDiscoveryQueue);
|
|
assert.equal((await (await anotherRequest('/api/greatest')).json()).entries[0].myRating, null);
|
|
assert.equal((await anotherRequest('/api/greatest/rating', { id: firstPick.id, rating: 'good' })).status, 200);
|
|
const communityPick = (await (await request('/api/greatest')).json()).entries[0];
|
|
assert.equal(communityPick.myRating, 'flame');
|
|
assert.deepEqual(communityPick.votes, { flame: 1, good: 1, neutral: 0, skip: 0 });
|
|
assert.equal((await request('/api/greatest/rating', { id: firstPick.id, rating: null })).status, 200);
|
|
assert.equal((await (await request('/api/greatest')).json()).entries[0].votes.flame, 0);
|
|
assert.equal((await request('/api/admin/user/delete', { name: 'another' })).status, 200);
|
|
assert.equal((await (await request('/api/greatest')).json()).entries[0].votes.good, 0);
|
|
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.equal(health.sentinel, undefined);
|
|
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 });
|
|
}
|
|
});
|