group editor implemented
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / Deploy to Server (push) Failing after 7s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s

This commit is contained in:
Florian Zumpe
2026-10-18 16:33:12 +02:00
parent 9c4d5f6324
commit 31768ae907
28 changed files with 2361 additions and 55 deletions
+133
View File
@@ -0,0 +1,133 @@
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.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);
});