Files
MangaCircus/test/services.test.js
MaashkinoozheandClaude Opus 5.5 fe8956087e Reading activity grid in Settings: months, weeks, days, hours with drill-down
Active reader time is now kept per user in small UTC buckets (6 minutes for
120 days, then hours, dropped after three years), seeded once from the visits
the clock still remembers. Settings -> Reading activity shows them as a grid
whose cells glow with their total and carry a start-to-end strip; month/week
cells open their days, days open their hours. Finished chapters come from
progress and work without the clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:41:44 +01:00

173 lines
14 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 }));
// The activity grid gets every credited millisecond once, duplicates and stale pulses excluded.
assert.equal((await request('/api/progress', { m: first.manga.id, c: first.chapter.id, v: first.version, p: 0, n: 1, done: true })).status, 200);
const activity = await (await request('/api/reading/activity')).json();
assert.equal(activity.readingClock, true);
assert.equal(activity.buckets.reduce((sum, b) => sum + b[2], 0), 2000);
assert.deepEqual(activity.mangas.map(m => m.id), [first.manga.id]);
assert.equal(activity.done.length, 1); assert.equal(activity.done[0][2], first.chapter.label);
assert.equal((await request('/api/reading/delete', { m: first.manga.id })).status, 200);
assert.equal((await (await request('/api/reading/activity')).json()).buckets.length, 0);
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 });
}
});