'use strict'; // Stale reading sessions: a reader tab left open on another device must not move "Continue" back. 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'); async function freePort() { const listener = net.createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve)); return port; } test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-')); let child; try { const library = path.join(root, 'library'), data = path.join(root, 'data'); for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) { const dir = path.join(library, 'Moon Garden', 'v01', ch); fs.mkdirSync(dir, { recursive: true }); for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png()); } fs.mkdirSync(data); const port = await freePort(), output = []; child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } }); child.stdout.on('data', d => output.push(String(d))); child.stderr.on('data', d => output.push(String(d))); const base = `http://127.0.0.1:${port}`; let ready = false; for (let n = 0; n < 100 && !ready; n++) { ready = await fetch(base + '/health').then(r => r.ok).catch(() => false); if (!ready) await new Promise(resolve => setTimeout(resolve, 50)); } assert.ok(ready, output.join('')); const post = (cookie, url, body) => fetch(base + url, { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json()); const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json()); const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'alice', password: 'alice-password' }) }); const alice = signup.headers.get('set-cookie').split(';')[0]; await post(alice, '/api/admin/rescan', { full: true }); const manga = await get(alice, '/api/manga?m=Moon%20Garden'); const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id })); const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra }); const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest); // Phone reads chapter 1, the tablet then reads on in chapter 3. const first = await save(one, { tab: 'phone-tab-1', seen: 0 }); assert.equal(first.ok, true); const phoneSeen = first.t; await new Promise(resolve => setTimeout(resolve, 5)); const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen }); assert.equal(tablet.ok, true); assert.equal((await latest()).c, three.c); // The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3. const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 }); assert.equal(refused.ok, false); assert.equal(refused.stale, true); assert.equal(refused.latest.c, three.c); assert.equal(refused.latest.w, 'tablet-tab-1'); assert.equal(refused.latest.continue.c, three.c); assert.match(refused.latest.continue.text, /Continue/); const after = await get(alice, '/api/manga?m=Moon%20Garden'); assert.equal(after.continue.c, three.c); assert.equal(after.continue.at, tablet.t); assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing'); // The chapter view tells a reader which head it is compared with. const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`); assert.equal(view.latest.c, three.c); assert.equal(view.latest.t, tablet.t); // The tablet's own saves never conflict with themselves, even with an older `seen` still in flight. assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true); // "Keep reading here": the phone now knows the newest time and its save becomes the head. const head = await latest(); const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 }); assert.equal(stayed.ok, true); assert.equal((await latest()).c, one.c); assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c); // Saves without `seen` (mark as read on the manga page, older clients) behave as before. assert.equal((await save(two, { done: true })).ok, true); assert.equal((await latest()).c, two.c); } finally { if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } fs.rmSync(root, { recursive: true, force: true }); } });