import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import vm from 'node:vm'; import path from 'node:path'; import { loadI18n } from '../test-support/i18n.js'; class Element extends EventTarget { constructor() { super(); this.children = []; this.open = false; } appendChild(child) { this.children.push(child); } replaceChildren(...children) { this.children = children; } showModal() { this.open = true; } close() { this.open = false; this.dispatchEvent(new Event('close')); } } const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); }; async function harness(records = new Map()) { const nodes = new Map(['point-compare-dialog','point-compare-content','point-compare-accept','point-compare-cancel'].map(id => [id, new Element()])); const document = new EventTarget(); document.createElement = () => new Element(); document.getElementById = id => nodes.get(id); const calls = [], controls = { accept: true, offline: false, locked: false, abort: false, version: 'v1', mediaVersion: 'm1' }; const poi = { id: 8, title: 'Start', description: 'Wiese', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25, audioUrl: '/api/routes/7/pois/8/audio', images: [{ id: 1, url: '/image.png', caption: 'Wiese' }] }; const database = { createObjectStore() {}, transaction() { const transaction = { error: new Error('quota'), objectStore() { const operation = (kind, value) => { const request = {}; queueMicrotask(() => { if (controls.abort) { transaction.onabort?.(); return; } if (kind === 'put') records.set(value.key, structuredClone(value)); if (kind === 'delete') records.delete(value); request.result = kind === 'get' ? structuredClone(records.get(value)) : kind === 'all' ? structuredClone([...records.values()]) : value; transaction.oncomplete(); }); return request; }; return { get: id => operation('get', id), getAll: () => operation('all'), put: value => operation('put', value), delete: id => operation('delete', id) }; }}; return transaction; }}; const context = { Event, File, Blob, document, indexedDB: { open() { const request = { result: database }; queueMicrotask(() => { request.onupgradeneeded(); request.onsuccess(); }); return request; } }, confirm: () => controls.accept, Wegwichtel: {}, fetch: async (url, options) => { const body = JSON.parse(options.body); calls.push({ url, body }); if (controls.offline) throw new TypeError('offline'); let status = 200, payload; if (body.heartbeat && !controls.locked) { status = 204; payload = {}; } else if (body.release) payload = { released: true }; else if (controls.locked && !body.replace) { status = 423; payload = { details: { code: 'point-locked', replace: 'old-secret' } }; } else payload = { token: 'our-secret', version: controls.version, mediaVersion: controls.mediaVersion, poi: structuredClone(poi) }; return { ok: status === 200, status, json: async () => payload }; }}; context.globalThis = context; await loadI18n(context); vm.runInNewContext(await fs.readFile('public/js/group-points.js', 'utf8'), context, { filename: path.resolve('public/js/group-points.js') }); return { api: context.Wegwichtel.GroupPoints, nodes, context, controls, calls, poi, records }; } const media = () => ({ images: [new File(['photo'], 'blume.png', { type: 'image/png' })], audio: new File(['sound'], 'vogel.mp3'), deletions: [{ path: 'pictures/1' }] }); test('point takeover requires confirmation, preserves baseline and binds writes to the editing token', async () => { const h = await harness(); h.controls.locked = true; h.controls.accept = false; assert.equal(await h.api.begin(7, h.poi), null); assert.equal(h.calls.length, 1); h.controls.accept = true; await h.api.begin(7, h.poi); assert.equal(h.calls.at(-1).body.replace, 'old-secret'); assert.equal(h.api.headersFor('/routes/7/pois/8/audio')['X-Poi-Lock'], 'our-secret'); assert.equal(Object.keys(h.api.headersFor('/routes/9/pois/8')).length, 0); h.api.observe({ headers: new Headers({ 'X-Poi-Version': 'v2', 'X-Poi-Id': '8' }) }); assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2'); await h.api.release(); assert.equal(h.calls.at(-1).body.token, 'our-secret'); assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0); }); test('unsent text, blobs, removals and baseline survive reopening; transaction abort is never reported as saved', async () => { const h = await harness(); await h.api.begin(7, h.poi); await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media()); const next = await harness(h.records); const draft = (await next.api.list())[0]; assert.equal(draft.baseVersion, 'v1'); assert.equal(draft.values.title, 'Mein Titel'); assert.equal(await draft.media.images[0].text(), 'photo'); assert.equal(await draft.media.audio.text(), 'sound'); const opened = await next.api.begin(7, next.poi); assert.equal(opened.draft.values.title, 'Mein Titel'); next.controls.abort = true; await assert.rejects(next.api.remember({ id: 7 }, 8, {}, {})); assert.equal(h.records.size, 1); next.controls.abort = false; await next.api.remove(7, 8); assert.equal((await next.api.list()).length, 0); }); test('retry compares all fields and media, retains cancelled work and only uses the latest version after consent', async () => { const h = await harness(); await h.api.begin(7, h.poi); const draft = await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media()); assert.ok(await h.api.prepare(draft)); h.controls.version = 'v2'; h.controls.mediaVersion = 'm2'; h.poi.description = 'Geändert'; const rejected = h.api.prepare(draft); await tick(); assert.equal(h.nodes.get('point-compare-dialog').open, true); const table = h.nodes.get('point-compare-content').children[0]; assert.equal(table.children[1].children[2].textContent, 'Mein Titel'); assert.equal(table.children[2].children[3].textContent, 'Geändert'); assert.match(h.nodes.get('point-compare-content').children[2].textContent, /wurden geändert/); h.nodes.get('point-compare-cancel').dispatchEvent(new Event('click')); assert.equal(await rejected, false); assert.equal((await h.api.list()).length, 1); const accepted = h.api.prepare(draft); await tick(); h.nodes.get('point-compare-accept').dispatchEvent(new Event('click')); assert.ok(await accepted); assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2'); }); test('offline drafts remain editable without claiming a server lock and a late lock does not reopen a closed editor', async () => { const h = await harness(); await h.api.begin(7, h.poi); await h.api.remember({ id: 7 }, 8, h.poi, media()); await h.api.release(); h.controls.offline = true; assert.ok((await h.api.begin(7, h.poi)).draft); assert.equal(h.api.headersFor('/routes/7/pois/8')['X-Poi-Lock'], ''); await assert.rejects(h.api.prepare((await h.api.list())[0])); h.controls.offline = false; let resolve; const original = h.context.fetch; h.context.fetch = (url, options) => { if (JSON.parse(options.body).release) return original(url, options); return new Promise(finish => { resolve = () => finish({ ok: true, json: async () => ({ token: 'late', version: 'v1', poi: h.poi }) }); }); }; const opening = h.api.begin(7, h.poi); await tick(); await h.api.release(); resolve(); assert.equal(await opening, null); assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0); }); test('only actual editor activity refreshes a point lock, while passive time and status checks do not', async () => { const h = await harness(); await h.api.begin(7, h.poi); await h.api.keepActive(); assert.equal(h.calls.filter(call => call.body.heartbeat).length, 0); h.api.noteActivity(); await h.api.keepActive(); await h.api.keepActive(); assert.equal(h.calls.filter(call => call.body.heartbeat).length, 1); assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v1'); h.controls.offline = true; h.api.noteActivity(); await h.api.keepActive(); h.controls.offline = false; await h.api.keepActive(); assert.equal(h.calls.filter(call => call.body.heartbeat).length, 3); let lost = 0; h.context.document.addEventListener('wegwichtel:point-lock-lost', () => { lost += 1; }); h.controls.locked = true; h.api.noteActivity(); await h.api.keepActive(); assert.equal(lost, 1); assert.equal(h.calls.at(-1).body.heartbeat, true); assert.equal(h.calls.at(-1).body.replace, undefined); });