Files
MangaCircus/test/services.test.js
T
MaashkinoozheandClaude Opus 5.5 1ddefaa7ac Library: "Continue where you left off" row with a per-user setting
One fixed row above the library showing the five most recently read,
unfinished manga with the normal tiles (they stay in the grid as well).
It ignores search and filters, hides while sorted by "Recently read",
and can be turned off under Settings > Reading > Library page
(continueRow, default on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:02:16 +01:00

164 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', GREATEST_COVER_WARM: '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 (await request('/api/me')).json()).settings.continueRow, true);
assert.equal((await (await request('/api/settings', { continueRow: false })).json()).settings.continueRow, false);
assert.equal((await (await request('/api/settings', { continueRow: 'no' })).json()).settings.continueRow, false);
assert.equal((await (await anotherRequest('/api/me')).json()).settings.continueRow, true);
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 });
}
});