group editor implemented
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'wegwichtel-groups-'));
|
||||
process.env.DATA_DIR = path.join(runtime, 'data');
|
||||
process.env.STORAGE_DIR = path.join(runtime, 'storage');
|
||||
const { config, ensureRuntimeDirectories } = await import('../src/config.js');
|
||||
const { openDatabase } = await import('../src/database.js');
|
||||
const { createRoute, createPoi } = await import('../src/services/routes-service.js');
|
||||
const { createPicture, createAudio } = await import('../src/services/media-service.js');
|
||||
const { resolveStoredFile } = await import('../src/services/storage.js');
|
||||
const { createApiRouter } = await import('../src/routes/api.js');
|
||||
const { notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
|
||||
const { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE } = await import('../src/services/collaboration.js');
|
||||
const express = (await import('express')).default;
|
||||
const gpx = '<gpx><metadata><name>Waldweg</name></metadata><trk><trkseg><trkpt lat="50" lon="10"/><trkpt lat="50.01" lon="10.01"/></trkseg></trk></gpx>';
|
||||
|
||||
async function fixture(t) {
|
||||
ensureRuntimeDirectories();
|
||||
const db = openDatabase(path.join(config.dataDir, `${crypto.randomUUID()}.sqlite`));
|
||||
const upload = { path: path.join(config.storageDir, 'uploads', `${crypto.randomUUID()}.gpx`) };
|
||||
fs.writeFileSync(upload.path, gpx);
|
||||
const source = createRoute(db, { name: 'Waldweg', creator_id: 'a'.repeat(64) }, upload);
|
||||
const poi = createPoi(db, source.id, { title: 'Start', description: 'Blumen', lat: 50, lon: 10 });
|
||||
for (const [kind, mime, extension] of [['picture', 'image/png', 'png'], ['audio', 'audio/mpeg', 'mp3']]) {
|
||||
const file = { path: path.join(config.storageDir, 'uploads', crypto.randomUUID()), detectedMime: mime, detectedExtension: extension };
|
||||
fs.writeFileSync(file.path, kind);
|
||||
if (kind === 'picture') createPicture(db, source.id, poi.id, { caption: 'Wiese' }, file);
|
||||
else createAudio(db, source.id, poi.id, file);
|
||||
}
|
||||
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', source.id);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', createApiRouter(db));
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
async function request(client, endpoint, method = 'GET', body = null, status = 200, headers = {}) {
|
||||
const response = await fetch(base + '/api' + endpoint, { method,
|
||||
headers: { ...(client.cookie ? { Cookie: client.cookie } : {}), 'Content-Type': 'application/json', ...headers },
|
||||
...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
if (cookie) client.cookie = cookie.split(';')[0];
|
||||
const value = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.text();
|
||||
assert.equal(response.status, status, JSON.stringify(value));
|
||||
return { value, response };
|
||||
}
|
||||
t.after(async () => { await new Promise(resolve => server.close(resolve)); db.close(); fs.rmSync(config.storageDir, { recursive: true, force: true }); });
|
||||
return { db, source, poi, request, base };
|
||||
}
|
||||
|
||||
test('shared draft clones a published route and protects its contents and ownership', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
|
||||
assert.equal(created.code.length, 8);
|
||||
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
|
||||
assert.match(response.headers.get('set-cookie'), /HttpOnly/);
|
||||
assert.match(response.headers.get('set-cookie'), /SameSite=Strict/);
|
||||
assert.match(host.cookie, new RegExp(`^${SESSION_COOKIE}=`));
|
||||
const route = created.group.route;
|
||||
assert.notEqual(route.id, source.id);
|
||||
assert.equal(route.activatedAt, null);
|
||||
assert.equal(route.creator_id, 'a'.repeat(64));
|
||||
assert.equal(route.pois[0].description, 'Blumen');
|
||||
assert.equal(route.pois[0].images[0].caption, 'Wiese');
|
||||
const oldImage = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(source.pois?.[0]?.id || 1);
|
||||
const image = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(route.pois[0].id);
|
||||
assert.notEqual(image.path, oldImage.path);
|
||||
assert.equal(fs.readFileSync(resolveStoredFile(image.path), 'utf8'), 'picture');
|
||||
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(route.pois[0].id);
|
||||
assert.equal(fs.readFileSync(resolveStoredFile(audio.audio_path), 'utf8'), 'audio');
|
||||
const list = (await request(outsider, '/routes')).value.routes;
|
||||
assert.equal(list.some(value => value.id === route.id), false);
|
||||
await request(outsider, `/routes/${route.id}`, 'GET', null, 401);
|
||||
for (const spelling of [`0${route.id}`, `${route.id}.0`, `${route.id}e0`, `0x${route.id.toString(16)}`, `%${String(route.id).charCodeAt(0).toString(16)}`]) {
|
||||
await request(outsider, `/routes/${spelling}`, 'GET', null, 401);
|
||||
await request(outsider, `/routes/${spelling}`, 'PUT', { name: 'Bypass' }, 401);
|
||||
}
|
||||
await request(outsider, `/Routes/${route.id}`, 'GET', null, 401);
|
||||
await request(outsider, route.pois[0].images[0].url.replace('/api', ''), 'GET', null, 401);
|
||||
const joined = (await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'a'.repeat(64) })).value.group;
|
||||
assert.equal(joined.role, 'member');
|
||||
await request(guest, `/groups/${created.group.id}`, 'DELETE', null, 403);
|
||||
const loaded = await request(guest, `/routes/${route.id}`);
|
||||
assert.equal(loaded.response.headers.get('x-group-revision'), '0');
|
||||
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 409);
|
||||
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 200, { 'If-Match': '0' });
|
||||
await request(host, `/routes/${route.id}`, 'PUT', { name: 'Lost update' }, 409, { 'If-Match': '0' });
|
||||
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(route.id).name, 'Changed');
|
||||
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(source.id).name, 'Waldweg');
|
||||
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
|
||||
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 403, { 'If-Match': '1' });
|
||||
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
await request(outsider, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
await request(guest, `/routes/${route.id}`, 'GET', null, 403);
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 0);
|
||||
await request(host, `/routes/${route.id}`);
|
||||
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
|
||||
assert.equal(reopened.group.active, true);
|
||||
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
|
||||
assert.equal((await request(guest, '/groups')).value.groups.length, 1);
|
||||
});
|
||||
|
||||
test('invitation expiry, request origin, invalid IDs and expired sessions are enforced', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
await request(host, '/groups', 'POST', { routeId: source.id }, 400);
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
|
||||
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
|
||||
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404);
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(host, `/groups/${'a'.repeat(32)}/code`, 'POST', {}, 404);
|
||||
db.prepare('UPDATE collaboration_groups SET code_expires_at = 0 WHERE id = ?').run(created.group.id);
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
|
||||
await request(guest, '/groups/join', 'POST', { code: ['invalid'] }, 404);
|
||||
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Cross site' }, 403,
|
||||
{ Origin: 'https://other.test', 'If-Match': '0' });
|
||||
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', created.group.route.id);
|
||||
await request(guest, `/routes/${created.group.route.id}`);
|
||||
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Published change' }, 409, { 'If-Match': '0' });
|
||||
db.prepare('UPDATE collaboration_sessions SET expires_at = 0').run();
|
||||
assert.equal((await request(host, '/groups')).value.groups.length, 0);
|
||||
await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 401);
|
||||
});
|
||||
|
||||
test('overlapping writes are locked until completion and revision changes once', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
const guard = groupRouteGuard(db);
|
||||
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } };
|
||||
const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
|
||||
let reached = false;
|
||||
guard(req, res, () => { reached = true; });
|
||||
assert.equal(reached, true);
|
||||
assert.throws(() => guard(req, res, () => {}), error => error.status === 409);
|
||||
res.emit('finish');
|
||||
assert.equal(db.prepare('SELECT revision FROM collaboration_groups').get().revision, 1);
|
||||
res.emit('close');
|
||||
});
|
||||
|
||||
test('group copying rolls back a failed media copy and rate limits invalid invitations', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
|
||||
fs.rmSync(resolveStoredFile(image.path));
|
||||
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
|
||||
const client = {};
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await request(client, '/groups/join', 'POST', { code: [] }, 404, { 'X-Real-IP': '192.0.2.99' });
|
||||
}
|
||||
await request(client, '/groups/join', 'POST', { code: [] }, 429, { 'X-Real-IP': '192.0.2.99' });
|
||||
});
|
||||
|
||||
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {}, outsider = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route, poi = route.pois[0];
|
||||
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
|
||||
const target = `/routes/${route.id}/pois/${poi.id}`;
|
||||
await request(outsider, endpoint, 'POST', { creator_id: 'a'.repeat(64) }, 401);
|
||||
await request(guest, target, 'PUT', { title: 'Not locked' }, 423, { 'If-Match': '0' });
|
||||
const lock = (await request(host, endpoint, 'POST', { creator_id: 'b'.repeat(64) })).value;
|
||||
const lockfile = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
|
||||
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'a'.repeat(64));
|
||||
assert.equal(fs.statSync(lockfile).mode & 0o777, 0o600);
|
||||
const conflict = (await request(guest, endpoint, 'POST', {}, 423)).value;
|
||||
assert.equal(conflict.details.code, 'point-locked');
|
||||
await request(guest, target, 'PUT', { title: 'Spoofed creator' }, 423,
|
||||
{ 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version, 'X-Creator-Id': 'a'.repeat(64) });
|
||||
await request(guest, endpoint, 'POST', { replace: 'outdated' }, 423);
|
||||
const takeover = (await request(guest, endpoint, 'POST', { replace: conflict.details.replace })).value;
|
||||
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'b'.repeat(64));
|
||||
await request(host, target, 'PUT', { title: 'Lost edit' }, 423, { 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version });
|
||||
await request(host, endpoint, 'POST', { release: true, token: lock.token }, 423);
|
||||
await request(host, `/Routes/0${route.id}/Pois/${poi.id}`, 'DELETE', {}, 423);
|
||||
const saved = await request(guest, target, 'PUT', { title: 'Guest edit' }, 200,
|
||||
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
|
||||
assert.ok(saved.response.headers.get('x-poi-version'));
|
||||
assert.notEqual(saved.response.headers.get('x-poi-version'), takeover.version);
|
||||
await request(guest, target, 'PUT', { title: 'Stale edit' }, 409,
|
||||
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
|
||||
const fresh = (await request(guest, endpoint, 'POST', { token: takeover.token })).value;
|
||||
// Same URL and same file count, but different bytes must invalidate the comparison.
|
||||
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(poi.id);
|
||||
fs.writeFileSync(resolveStoredFile(audio.audio_path), 'changed audio bytes');
|
||||
await request(guest, `${target}/audio`, 'DELETE', null, 409,
|
||||
{ 'X-Poi-Lock': fresh.token, 'If-Poi-Match': fresh.version });
|
||||
const latest = (await request(guest, endpoint, 'POST', { token: fresh.token })).value;
|
||||
assert.notEqual(latest.mediaVersion, fresh.mediaVersion);
|
||||
const removed = await request(guest, `${target}/audio`, 'DELETE', null, 200,
|
||||
{ 'X-Poi-Lock': latest.token, 'If-Poi-Match': latest.version });
|
||||
assert.ok(removed.response.headers.get('x-poi-version'));
|
||||
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
|
||||
assert.equal(fs.existsSync(lockfile), false);
|
||||
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
|
||||
await request(host, endpoint, 'POST', {});
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE');
|
||||
assert.equal(fs.existsSync(lockfile), false);
|
||||
});
|
||||
|
||||
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => {
|
||||
const { db, source, request } = await fixture(t);
|
||||
const host = {}, guest = {};
|
||||
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
|
||||
const route = created.group.route;
|
||||
const second = createPoi(db, route.id, { title: 'Second stop' });
|
||||
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
|
||||
const first = route.pois[0];
|
||||
const a = (await request(host, lockPath(first.id), 'POST', {})).value;
|
||||
const b = (await request(guest, lockPath(second.id), 'POST', {})).value;
|
||||
await request(host, `/routes/${route.id}/pois/${first.id}`, 'PUT', { title: 'A' }, 200,
|
||||
{ 'X-Poi-Lock': a.token, 'If-Poi-Match': a.version, 'If-Match': '0' });
|
||||
await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
|
||||
{ 'X-Poi-Lock': b.token, 'If-Poi-Match': b.version, 'If-Match': '0' });
|
||||
const fresh = (await request(host, lockPath(first.id), 'POST', { token: a.token })).value;
|
||||
const req = { method: 'PUT', path: `/routes/${route.id}/pois/${first.id}`, headers: {
|
||||
cookie: host.cookie, 'x-poi-lock': fresh.token, 'if-poi-match': fresh.version } };
|
||||
const res = new EventEmitter(); res.setHeader = () => {}; res.json = () => {}; res.statusCode = 200;
|
||||
groupRouteGuard(db)(req, res, () => {});
|
||||
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token }, 423);
|
||||
await request(host, lockPath(first.id), 'POST', { release: true, token: fresh.token }, 423);
|
||||
await request(host, `/groups/${created.group.id}`, 'DELETE', {}, 423);
|
||||
res.emit('close');
|
||||
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token });
|
||||
assert.throws(() => req.validatePointWrite(), error => error.status === 423);
|
||||
});
|
||||
+141
-1
@@ -826,7 +826,7 @@ test('editing an activated server route uses PUT and never writes activation sta
|
||||
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
|
||||
assert.ok(update);
|
||||
const body = JSON.parse(update.body);
|
||||
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
|
||||
assert.deepEqual(Object.keys(body).sort(), ['creator_id', 'description', 'name']);
|
||||
assert.equal('activated_at' in body, false);
|
||||
assert.equal('activatedAt' in body, false);
|
||||
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
|
||||
@@ -963,3 +963,143 @@ test('late station previews are released after the editor closes without replaci
|
||||
assert.equal(released.length, 1);
|
||||
assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
|
||||
});
|
||||
|
||||
test('shared drafts use guarded server saves, preserve the creator and retain unsaved input on conflicts', async () => {
|
||||
const h = buildEditorContext();
|
||||
const shared = { id: 44, name: 'Gemeinsamer Waldweg', description: '', creator_id: 'b'.repeat(64), pois: [] };
|
||||
let conflict = false;
|
||||
let refreshed = 0;
|
||||
const originalFetch = h.context.fetch;
|
||||
h.context.Wegwichtel.Groups = {
|
||||
headersFor: path => path.startsWith('/routes/44') ? { 'If-Match': '3' } : {},
|
||||
observe() {}, refresh() { refreshed += 1; }
|
||||
};
|
||||
h.context.fetch = async (url, options = {}) => {
|
||||
if (url === '/api/routes/44') {
|
||||
h.calls.push({ url, ...options, method: options.method || 'GET' });
|
||||
if (options.method === 'PUT') {
|
||||
if (conflict) return { ...jsonResponse({ message: 'Conflict' }, false, 409), headers: { get: () => '44' } };
|
||||
Object.assign(shared, JSON.parse(options.body));
|
||||
}
|
||||
return jsonResponse(shared);
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
await loadI18n(h.context);
|
||||
const messages = path.join(root, 'public/js/user-messages.js');
|
||||
vm.runInNewContext(await fs.readFile(messages, 'utf8'), h.context, { filename: messages });
|
||||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
|
||||
await h.context.Wegwichtel.EditorUi.openGroup(shared);
|
||||
assert.equal(h.controls.recording, null);
|
||||
assert.ok(refreshed > 0);
|
||||
h.elements.get('editor-route-name').value = 'Gemeinsam geändert';
|
||||
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||
for (let i = 0; i < 6; i += 1) await tick();
|
||||
const saved = h.calls.find(call => call.method === 'PUT' && call.url === '/api/routes/44');
|
||||
assert.equal(saved.headers['If-Match'], '3');
|
||||
assert.equal(JSON.parse(saved.body).creator_id, 'b'.repeat(64));
|
||||
assert.equal(h.packages.has('44'), false);
|
||||
assert.equal(shared.name, 'Gemeinsam geändert');
|
||||
conflict = true;
|
||||
h.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
|
||||
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||
for (let i = 0; i < 6; i += 1) await tick();
|
||||
assert.equal(shared.name, 'Gemeinsam geändert');
|
||||
assert.equal(h.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
|
||||
assert.match(h.elements.get('editor-status').textContent, /inzwischen geändert/);
|
||||
});
|
||||
|
||||
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
|
||||
const h = buildEditorContext();
|
||||
const sharedIds = [];
|
||||
h.context.confirm = () => false;
|
||||
h.context.Wegwichtel.Groups = {
|
||||
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
|
||||
async share(id) { sharedIds.push(id); }
|
||||
};
|
||||
await loadI18n(h.context);
|
||||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
|
||||
await h.context.Wegwichtel.EditorUi.open();
|
||||
const list = h.elements.get('editor-route-list');
|
||||
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(h.calls.some(call => call.method === 'POST'), false);
|
||||
h.context.confirm = () => true;
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||||
for (let i = 0; i < 14; i += 1) await tick();
|
||||
assert.deepEqual(sharedIds, ['22']);
|
||||
assert.equal(h.packages.get('local-existing').route.serverRouteId, '22');
|
||||
assert.ok(h.calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
|
||||
});
|
||||
|
||||
test('shared station saves persist files before sending, retain lost-lock changes and release locks on exit', async () => {
|
||||
const h = buildEditorContext();
|
||||
const poi = { id: 8, title: 'Wiese', description: 'Blumen', lat: 50, lon: 10, sequence: 0, images: [], audioUrl: null };
|
||||
const route = { id: 44, name: 'Gruppenweg', creator_id: 'a'.repeat(64), pois: [poi] };
|
||||
const events = [], drafts = new Map();
|
||||
let allowed = false;
|
||||
h.context.Wegwichtel.GroupPoints = {
|
||||
async begin() { events.push('lock'); return { poi }; },
|
||||
async release() { events.push('release'); },
|
||||
async read(id, poiId) { return drafts.get(String(poiId)); },
|
||||
async remember(route, poiId, values, media) { const draft = { route, poiId, values, media }; events.push('backup'); drafts.set(String(poiId), structuredClone(draft)); return draft; },
|
||||
async prepare() { events.push('prepare'); return allowed ? { poi } : false; },
|
||||
async remove(id, poiId) { events.push('remove'); drafts.delete(String(poiId)); },
|
||||
headersFor: () => ({ 'X-Poi-Lock': 'secret', 'If-Poi-Match': 'v1' }), observe() {},
|
||||
async acceptNew() {}
|
||||
};
|
||||
const original = h.context.fetch;
|
||||
h.context.fetch = async (url, options = {}) => {
|
||||
if (url.startsWith('/api/routes/44')) {
|
||||
h.calls.push({ url, ...options, method: options.method || 'GET' });
|
||||
if (url === '/api/routes/44') return jsonResponse(route);
|
||||
if (options.method === 'PUT') { events.push('send'); Object.assign(poi, JSON.parse(options.body)); return jsonResponse({ poi }); }
|
||||
if (options.method === 'DELETE') { events.push('delete'); return jsonResponse({ deleted: true }); }
|
||||
}
|
||||
return original(url, options);
|
||||
};
|
||||
await loadI18n(h.context);
|
||||
vm.runInNewContext(await fs.readFile('public/js/editor-ui.js', 'utf8'), h.context, { filename: path.resolve('public/js/editor-ui.js') });
|
||||
await h.context.Wegwichtel.EditorUi.openGroup(route);
|
||||
const list = h.elements.get('editor-poi-list');
|
||||
list.dispatchEvent(pointerEvent(list.children[0].children[1].children[2])); await tick();
|
||||
h.elements.get('editor-poi-title').value = 'Meine Blumen';
|
||||
const file = new Blob(['photo'], { type: 'image/png' }); file.name = 'blume.png';
|
||||
h.elements.get('editor-poi-images').files = [file];
|
||||
h.elements.get('editor-poi-images').dispatchEvent(new Event('change')); await tick();
|
||||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick();
|
||||
assert.equal(events.includes('send'), false);
|
||||
assert.equal(drafts.get('8').values.title, 'Meine Blumen');
|
||||
assert.equal(drafts.get('8').media.images.length, 1);
|
||||
assert.match(h.elements.get('editor-status').textContent, /auf diesem Gerät gesichert/);
|
||||
// Remove the selected picture, then retry the same text edit with the lock held.
|
||||
await h.context.Wegwichtel.EditorUi.openPendingPoint({ route, poiId: 8, basePoi: poi, values: poi, media: {} });
|
||||
allowed = true;
|
||||
h.elements.get('editor-poi-title').value = 'Gemeinsam gespeichert';
|
||||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||
for (let i = 0; i < 6; i += 1) await tick();
|
||||
assert.equal(events.filter(event => event === 'send').length, 1);
|
||||
assert.ok(events.indexOf('backup') < events.indexOf('send'));
|
||||
assert.equal(drafts.size, 0);
|
||||
assert.equal(poi.title, 'Gemeinsam gespeichert');
|
||||
const saved = h.calls.find(call => call.method === 'PUT');
|
||||
assert.equal(saved.headers['X-Poi-Lock'], 'secret');
|
||||
allowed = false;
|
||||
const latestList = h.elements.get('editor-poi-list');
|
||||
latestList.dispatchEvent(pointerEvent(latestList.children[0].children[1].children[3]));
|
||||
for (let i = 0; i < 4; i += 1) await tick();
|
||||
assert.equal(drafts.get('8').media.deletePoint, true);
|
||||
assert.equal(events.includes('delete'), false);
|
||||
allowed = true;
|
||||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||
for (let i = 0; i < 5; i += 1) await tick();
|
||||
assert.equal(events.includes('delete'), true);
|
||||
assert.equal(drafts.size, 0);
|
||||
h.context.Wegwichtel.EditorUi.close();
|
||||
assert.equal(events.at(-1), 'release');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { loadI18n } from '../test-support/i18n.js';
|
||||
|
||||
class Element extends EventTarget {
|
||||
constructor() { super(); this.children = []; this.attributes = new Map(); this.dataset = {}; this.scrollTop = 0; this.open = false; }
|
||||
appendChild(node) { this.children.push(node); return node; }
|
||||
append(...nodes) { nodes.forEach(node => this.appendChild(node)); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')); }
|
||||
}
|
||||
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
|
||||
function pointer(node, properties = {}) {
|
||||
const event = new Event('pointerup', { cancelable: true });
|
||||
for (const [key, value] of Object.entries(properties)) Object.defineProperty(event, key, { value });
|
||||
node.dispatchEvent(event);
|
||||
}
|
||||
function key(node, value) {
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value }); node.dispatchEvent(event);
|
||||
}
|
||||
async function harness(loading = false) {
|
||||
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
|
||||
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
|
||||
const nodes = new Map(ids.map(id => [id, new Element()]));
|
||||
const document = new EventTarget();
|
||||
document.readyState = loading ? 'loading' : 'complete';
|
||||
document.getElementById = id => nodes.get(id);
|
||||
document.createElement = () => new Element();
|
||||
const calls = [], edits = [];
|
||||
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
|
||||
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
|
||||
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
|
||||
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
|
||||
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
|
||||
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, ...options });
|
||||
if (controls.offline) throw new TypeError('Failed to fetch');
|
||||
if (controls.error) return { ok: false, status: controls.error };
|
||||
let value;
|
||||
if (options.method === 'GET') value = { groups: [group()] };
|
||||
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
|
||||
else if (url.endsWith('/join')) value = { group: group() };
|
||||
else value = { group: group(), code: Array(8).fill('🐦'), expiresAt: 1800000000 };
|
||||
return { ok: true, json: async () => value };
|
||||
}
|
||||
};
|
||||
context.globalThis = context;
|
||||
await loadI18n(context);
|
||||
for (const module of ['group-symbols', 'groups']) {
|
||||
const filename = path.resolve('public/js', module + '.js');
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
|
||||
}
|
||||
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
|
||||
}
|
||||
|
||||
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
|
||||
const h = await harness(true);
|
||||
h.context.document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
pointer(h.nodes.get('group-join-open'), { isPrimary: false });
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
pointer(h.nodes.get('group-join-open'));
|
||||
assert.equal(h.nodes.get('group-dialog').open, true);
|
||||
const columns = h.nodes.get('group-wheels').children;
|
||||
assert.equal(columns.length, 8);
|
||||
const [up, viewport, down] = columns[0].children;
|
||||
assert.equal(viewport.attributes.get('role'), 'spinbutton');
|
||||
key(up, 'Enter');
|
||||
assert.equal(viewport.attributes.get('aria-valuenow'), '9');
|
||||
pointer(down);
|
||||
assert.equal(viewport.attributes.get('aria-valuenow'), '1');
|
||||
key(viewport, 'End'); key(viewport, 'ArrowUp');
|
||||
assert.equal(viewport.attributes.get('aria-valuenow'), '8');
|
||||
key(viewport, 'Home'); key(viewport, 'ArrowDown');
|
||||
pointer(viewport.children[5]);
|
||||
assert.equal(viewport.attributes.get('aria-valuetext'), 'Vogel');
|
||||
viewport.scrollTop = 3 * 52; viewport.dispatchEvent(new Event('scroll'));
|
||||
key(h.nodes.get('group-join'), ' ');
|
||||
await tick();
|
||||
const joined = h.calls.find(call => call.url.endsWith('/join'));
|
||||
const body = JSON.parse(joined.body);
|
||||
assert.equal(body.code.length, 8);
|
||||
assert.equal(body.code[0], '🌼');
|
||||
assert.equal(body.creator_id, 'a'.repeat(64));
|
||||
assert.deepEqual(h.edits, [22]);
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
pointer(h.nodes.get('editor-group-join'));
|
||||
pointer(h.nodes.get('group-close'));
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
});
|
||||
|
||||
test('sharing displays eight motifs and the owner can renew or close access with confirmation', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
|
||||
assert.equal(h.nodes.get('group-code').children.length, 8);
|
||||
assert.equal(h.nodes.get('group-entry').hidden, true);
|
||||
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
|
||||
const shareButton = h.groups.shareButton('share-route');
|
||||
assert.equal(shareButton.dataset.editorAction, 'share-route');
|
||||
assert.equal(shareButton.children.length, 2);
|
||||
pointer(h.nodes.get('group-edit')); await tick();
|
||||
assert.deepEqual(h.edits, [22]);
|
||||
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
h.controls.confirm = false;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
|
||||
h.controls.confirm = true;
|
||||
pointer(actions[2]); await tick();
|
||||
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
|
||||
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
|
||||
pointer(actions[1]); await tick();
|
||||
assert.equal(h.edits.length, 2);
|
||||
pointer(actions[3]); await tick();
|
||||
assert.ok(h.calls.some(call => call.method === 'DELETE'));
|
||||
h.controls.role = 'member'; await h.groups.refresh();
|
||||
assert.equal(h.nodes.get('editor-group-list').children[0].children[1].children.length, 2);
|
||||
});
|
||||
|
||||
test('code failures are localized and group list refresh never advances an editing revision', async () => {
|
||||
const h = await harness();
|
||||
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], undefined);
|
||||
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': '3' }) });
|
||||
assert.equal(h.groups.headersFor('/routes/22/pois/1')['If-Match'], '3');
|
||||
await h.groups.refresh();
|
||||
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
|
||||
assert.equal(h.groups.headersFor('/routes/23')['If-Match'], undefined);
|
||||
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': 'invalid' }) });
|
||||
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
|
||||
pointer(h.nodes.get('group-join-open'));
|
||||
for (const [status, text] of [[404, /Code stimmt/], [401, /nicht geöffnet/], [403, /nicht geöffnet/], [409, /inzwischen geändert/], [429, /eine Minute/], [500, /Internetverbindung/]]) {
|
||||
h.controls.error = status;
|
||||
pointer(h.nodes.get('group-join')); await tick();
|
||||
assert.match(h.nodes.get('group-status').textContent, text);
|
||||
assert.equal(h.nodes.get('group-join').disabled, false);
|
||||
}
|
||||
h.controls.error = 0; h.controls.offline = true;
|
||||
h.context.Wegwichtel.I18n.setLanguage('en', null);
|
||||
pointer(h.nodes.get('group-join')); await tick();
|
||||
assert.match(h.nodes.get('group-status').textContent, /internet connection/);
|
||||
await h.groups.refresh();
|
||||
});
|
||||
|
||||
test('a late join response does not reopen a cancelled dialog or editor', async () => {
|
||||
const h = await harness();
|
||||
const original = h.context.fetch;
|
||||
let release;
|
||||
h.context.fetch = async (url, options) => {
|
||||
if (url.endsWith('/join')) await new Promise(resolve => { release = resolve; });
|
||||
return original(url, options);
|
||||
};
|
||||
pointer(h.nodes.get('group-join-open'));
|
||||
pointer(h.nodes.get('group-join'));
|
||||
await tick();
|
||||
pointer(h.nodes.get('group-close'));
|
||||
release();
|
||||
await tick();
|
||||
assert.equal(h.nodes.get('group-dialog').open, false);
|
||||
assert.deepEqual(h.edits, []);
|
||||
});
|
||||
|
||||
test('unsent station changes are listed before network access and discarded only after confirmation', async () => {
|
||||
const h = await harness();
|
||||
const list = new Element(); h.nodes.set('editor-point-drafts', list);
|
||||
const drafts = [{ route: { id: 7, name: 'Waldweg' }, poiId: 8, values: { title: 'Blumen' } }];
|
||||
const opened = [];
|
||||
h.context.Wegwichtel.EditorUi.openPendingPoint = async draft => opened.push(draft.poiId);
|
||||
h.context.Wegwichtel.GroupPoints = { async list() { return drafts; }, async remove() { drafts.splice(0); } };
|
||||
h.controls.offline = true;
|
||||
await h.groups.refresh();
|
||||
assert.equal(list.children.length, 1);
|
||||
pointer(list.children[0].children[1].children[0]); await tick();
|
||||
assert.deepEqual(opened, [8]);
|
||||
h.controls.confirm = false;
|
||||
pointer(list.children[0].children[1].children[1]); await tick();
|
||||
assert.equal(drafts.length, 1);
|
||||
h.controls.confirm = true;
|
||||
pointer(list.children[0].children[1].children[1]); await tick();
|
||||
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
|
||||
assert.equal(list.children.length, 0);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ test('start screen exposes cookie status, optional local progress and deliberate
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
const storage = await read('public/js/privacy-storage.js');
|
||||
|
||||
assert.match(html, /Wegwichtel verwendet keine Cookies/);
|
||||
assert.match(html, /Wegwichtel verwendet keine Werbe- oder Tracking-Cookies/);
|
||||
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
|
||||
assert.match(html, /class="progress-storage-toggle"/);
|
||||
assert.match(html, /role="switch"[^>]*aria-checked="false"/);
|
||||
|
||||
@@ -34,7 +34,7 @@ test('every static HTML id is referenced by client JavaScript or accessible HTML
|
||||
|
||||
test('every static HTML class is used by CSS or authored JavaScript', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}`;
|
||||
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}\n${await read('public/css/editor.css')}`;
|
||||
const scripts = (await clientSources()).join('\n');
|
||||
const classes = [...html.matchAll(/\bclass="([^"]+)"/g)]
|
||||
.flatMap(match => match[1].split(/\s+/))
|
||||
|
||||
@@ -29,6 +29,27 @@ const EXPECTED_SCHEMA = Object.freeze({
|
||||
activated_at: { type: 'TEXT' },
|
||||
trash_path: { type: 'TEXT' }
|
||||
},
|
||||
collaboration_sessions: {
|
||||
token_hash: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
creator_id: { type: 'TEXT', notnull: 1 },
|
||||
expires_at: { type: 'INTEGER', notnull: 1 }
|
||||
},
|
||||
collaboration_groups: {
|
||||
id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
route_id: { type: 'INTEGER', notnull: 1 },
|
||||
source_route_id: { type: 'INTEGER' },
|
||||
owner_creator_id: { type: 'TEXT', notnull: 1 },
|
||||
code_hash: { type: 'TEXT' },
|
||||
code_expires_at: { type: 'INTEGER', notnull: 1 },
|
||||
active: { type: 'INTEGER', notnull: 1 },
|
||||
revision: { type: 'INTEGER', notnull: 1 },
|
||||
created_at: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
collaboration_members: {
|
||||
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
|
||||
session_hash: { type: 'TEXT', notnull: 1, pk: 2 },
|
||||
role: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
pois: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
route_id: { type: 'INTEGER', notnull: 1 },
|
||||
|
||||
Reference in New Issue
Block a user