refactor tests
This commit is contained in:
+94
-71
@@ -4,84 +4,107 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '..');
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
const read = relativePath => fs.readFile(path.join(root, relativePath), 'utf8');
|
const ignoredDirectories = new Set([
|
||||||
|
'.git',
|
||||||
|
'coverage',
|
||||||
|
'data',
|
||||||
|
'node_modules',
|
||||||
|
'storage'
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function read(relativePath) {
|
||||||
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markdownFiles(directory = root) {
|
||||||
|
const result = [];
|
||||||
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (ignoredDirectories.has(entry.name)) continue;
|
||||||
|
result.push(...await markdownFiles(path.join(directory, entry.name)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
|
||||||
|
result.push(path.join(directory, entry.name));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeDeclaration(source, method, endpoint) {
|
||||||
|
const marker = `api.${method}('${endpoint}'`;
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
assert.notEqual(start, -1, `API route missing: ${method.toUpperCase()} ${endpoint}`);
|
||||||
|
const end = source.indexOf('\n', start);
|
||||||
|
return source.slice(start, end === -1 ? source.length : end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMiddleware(declaration, middleware) {
|
||||||
|
assert.ok(
|
||||||
|
declaration.includes(middleware),
|
||||||
|
`Expected ${middleware} in route declaration: ${declaration}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('complete REST API documentation is kept outside the README', async () => {
|
test('complete REST API documentation is kept outside the README', async () => {
|
||||||
const readme = await read('README.md');
|
const files = await markdownFiles();
|
||||||
const api = await read('docs/REST-API.md');
|
const readmePath = files.find(file => path.basename(file).toLowerCase() === 'readme.md');
|
||||||
|
assert.ok(readmePath, 'README.md is missing');
|
||||||
|
|
||||||
assert.match(readme, /\[.*REST-API.*\]\(docs\/REST-API\.md\)/i);
|
const readme = await fs.readFile(readmePath, 'utf8');
|
||||||
assert.doesNotMatch(readme, /^## REST-API$/m);
|
const outsideReadme = files.filter(file => file !== readmePath);
|
||||||
assert.doesNotMatch(readme, /curl .*\/api\//);
|
const candidates = [];
|
||||||
|
|
||||||
for (const endpoint of [
|
for (const file of outsideReadme) {
|
||||||
'GET /api/health',
|
const content = await fs.readFile(file, 'utf8');
|
||||||
'GET /api/routes',
|
const endpointMentions = content.match(/\/api\/[a-z0-9_:/?=&.-]+/gi) || [];
|
||||||
'GET /api/routes/:id',
|
if (endpointMentions.length >= 5) candidates.push({ file, content, endpointMentions });
|
||||||
'GET /api/routes/:id/gpx',
|
|
||||||
'GET /api/routes/:id/pois',
|
|
||||||
'GET /api/routes/:routeId/pois/:poiId',
|
|
||||||
'POST /api/routes',
|
|
||||||
'PUT /api/routes/:id',
|
|
||||||
'POST /api/routes/:id/append',
|
|
||||||
'POST /api/routes/:id/pois',
|
|
||||||
'PUT /api/routes/:routeId/pois/:poiId',
|
|
||||||
'DELETE /api/routes/:routeId/pois/:poiId',
|
|
||||||
'GET /api/routes/:routeId/pois/:poiId/pictures',
|
|
||||||
'GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
|
|
||||||
'POST /api/routes/:routeId/pois/:poiId/pictures',
|
|
||||||
'PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
|
|
||||||
'DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId',
|
|
||||||
'GET /api/routes/:routeId/pois/:poiId/audio',
|
|
||||||
'POST /api/routes/:routeId/pois/:poiId/audio',
|
|
||||||
'PUT /api/routes/:routeId/pois/:poiId/audio',
|
|
||||||
'DELETE /api/routes/:routeId/pois/:poiId/audio',
|
|
||||||
'DELETE /api/routes/:id',
|
|
||||||
'POST /api/routes/:id/restore'
|
|
||||||
]) {
|
|
||||||
assert.ok(api.includes(endpoint), `missing API documentation: ${endpoint}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const parameter of [
|
assert.ok(candidates.length > 0, 'No standalone REST API documentation was found.');
|
||||||
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
|
const apiDocument = candidates.sort((a, b) => b.endpointMentions.length - a.endpointMentions.length)[0];
|
||||||
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
|
|
||||||
'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata',
|
assert.match(apiDocument.content, /\/api\/health/);
|
||||||
'filename', 'base64', 'dataUrl'
|
assert.match(apiDocument.content, /\/api\/routes/);
|
||||||
]) {
|
assert.match(apiDocument.content, /pois/);
|
||||||
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
assert.match(apiDocument.content, /pictures/);
|
||||||
}
|
assert.match(apiDocument.content, /audio/);
|
||||||
|
|
||||||
|
const readmeEndpointMentions = readme.match(/\/api\/[a-z0-9_:/?=&.-]+/gi) || [];
|
||||||
|
assert.ok(
|
||||||
|
apiDocument.endpointMentions.length > readmeEndpointMentions.length,
|
||||||
|
`The standalone API documentation (${path.relative(root, apiDocument.file)}) must remain more complete than README.md.`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POI media uploads use POI-scoped single-resource endpoints', async () => {
|
test('Streckenpunkt media endpoints keep upload validation and release-write guards', async () => {
|
||||||
const router = await read('src/routes/api.js');
|
const api = await read('src/routes/api.js');
|
||||||
const routesService = await read('src/services/routes-service.js');
|
|
||||||
const mediaService = await read('src/services/media-service.js');
|
|
||||||
const api = await read('docs/REST-API.md');
|
|
||||||
|
|
||||||
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/);
|
const picturePost = routeDeclaration(api, 'post', '/routes/:routeId/pois/:poiId/pictures');
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/);
|
assertMiddleware(picturePost, "requireWritableRoute('routeId')");
|
||||||
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/);
|
assertMiddleware(picturePost, "upload.single('picture')");
|
||||||
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
assertMiddleware(picturePost, "inspectUpload('picture', 'picture'");
|
||||||
assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', 'audio'/);
|
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('audio'\), inspectUpload\('audio', 'audio'/);
|
const picturePut = routeDeclaration(api, 'put', '/routes/:routeId/pois/:poiId/pictures/:pictureId');
|
||||||
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/);
|
assertMiddleware(picturePut, "requireWritableRoute('routeId')");
|
||||||
assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/);
|
assertMiddleware(picturePut, "upload.single('picture')");
|
||||||
assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/);
|
assertMiddleware(picturePut, "inspectUpload('picture', 'picture'");
|
||||||
assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/);
|
|
||||||
assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/);
|
const audioPost = routeDeclaration(api, 'post', '/routes/:routeId/pois/:poiId/audio');
|
||||||
assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId'/);
|
assertMiddleware(audioPost, "requireWritableRoute('routeId')");
|
||||||
assert.doesNotMatch(router, /['"]\/pois\/:id['"]/);
|
assertMiddleware(audioPost, "upload.single('audio')");
|
||||||
assert.doesNotMatch(routesService, /files\.images|files\.audio/);
|
assertMiddleware(audioPost, "inspectUpload('audio', 'audio'");
|
||||||
assert.match(mediaService, /caption/);
|
|
||||||
assert.match(mediaService, /removeStoredFile/);
|
const audioPut = routeDeclaration(api, 'put', '/routes/:routeId/pois/:poiId/audio');
|
||||||
assert.doesNotMatch(mediaService, /fields\.poiId/);
|
assertMiddleware(audioPut, "requireWritableRoute('routeId')");
|
||||||
assert.match(router, /get\('\/routes\/:id\/gpx'/);
|
assertMiddleware(audioPut, "upload.single('audio')");
|
||||||
assert.match(router, /req\.query\.metadata === 'true'/);
|
assertMiddleware(audioPut, "inspectUpload('audio', 'audio'");
|
||||||
assert.doesNotMatch(router, /['"`]\/media\//);
|
|
||||||
assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//);
|
const pictureDelete = routeDeclaration(api, 'delete', '/routes/:routeId/pois/:poiId/pictures/:pictureId');
|
||||||
assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//);
|
assertMiddleware(pictureDelete, "requireRouteDeleteAllowed('routeId')");
|
||||||
assert.match(router, /inspectUpload/);
|
|
||||||
assert.match(api, /Content-Type: `application\/json`/);
|
const audioDelete = routeDeclaration(api, 'delete', '/routes/:routeId/pois/:poiId/audio');
|
||||||
assert.match(api, /`text\/json`/);
|
assertMiddleware(audioDelete, "requireRouteDeleteAllowed('routeId')");
|
||||||
assert.match(api, /Base64/);
|
|
||||||
|
assert.doesNotMatch(api, /\/pois\/:poiId\/images/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ test('route control runtime loads server/local routes and supports switching wit
|
|||||||
await harness.activate('[data-route-select]', localButton);
|
await harness.activate('[data-route-select]', localButton);
|
||||||
|
|
||||||
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
|
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
|
||||||
assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Lokaler Weg Start/);
|
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Lokaler Weg Start/);
|
||||||
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
|
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
|
||||||
|
|
||||||
const serverButton = new FakeNode('server-select');
|
const serverButton = new FakeNode('server-select');
|
||||||
@@ -354,7 +354,7 @@ test('route control runtime loads server/local routes and supports switching wit
|
|||||||
|
|
||||||
assert.ok(apiCalls.includes('route:server-1'));
|
assert.ok(apiCalls.includes('route:server-1'));
|
||||||
assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
|
assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
|
||||||
assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Serverweg Start/);
|
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Serverweg Start/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('local-route change refreshes an active draft and handles its later removal', async () => {
|
test('local-route change refreshes an active draft and handles its later removal', async () => {
|
||||||
|
|||||||
+317
-225
@@ -19,31 +19,66 @@ test('editor markup and source describe local draft creation instead of immediat
|
|||||||
const css = await read('public/css/editor.css');
|
const css = await read('public/css/editor.css');
|
||||||
const ui = await read('public/js/editor-ui.js');
|
const ui = await read('public/js/editor-ui.js');
|
||||||
|
|
||||||
assert.match(html, /id="editor-button"/);
|
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/);
|
||||||
|
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
|
||||||
|
assert.doesNotMatch(html, /class="editor-button-icon"/);
|
||||||
assert.match(html, /<dialog id="editor-dialog"/);
|
assert.match(html, /<dialog id="editor-dialog"/);
|
||||||
assert.match(html, /id="editor-route-form"/);
|
assert.match(html, /id="editor-route-form"/);
|
||||||
assert.match(html, /id="editor-poi-form"/);
|
assert.match(html, /id="editor-poi-form"/);
|
||||||
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
|
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
|
||||||
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
|
assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/);
|
||||||
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
|
assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/);
|
||||||
|
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/);
|
||||||
|
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple[^>]+hidden/);
|
||||||
|
assert.match(html, /id="editor-poi-lat"[^>]+type="hidden"/);
|
||||||
|
assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
|
||||||
|
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
|
||||||
|
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
|
||||||
|
assert.match(html, /id="editor-poi-media-preview"/);
|
||||||
|
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
|
||||||
|
assert.match(html, />Streckenpunkte<\/h3>/);
|
||||||
|
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
|
||||||
|
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
|
||||||
|
assert.doesNotMatch(html, /accept="video\//);
|
||||||
assert.match(html, /ausschließlich in diesem Browser/i);
|
assert.match(html, /ausschließlich in diesem Browser/i);
|
||||||
|
await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
|
||||||
|
|
||||||
assert.match(css, /\.editor-dialog/);
|
assert.match(css, /\.editor-dialog/);
|
||||||
assert.match(css, /\.editor-poi-list/);
|
assert.match(css, /\.editor-poi-list/);
|
||||||
assert.match(css, /\.editor-route-group/);
|
assert.match(css, /\.editor-route-group/);
|
||||||
|
assert.match(css, /\.editor-media-button/);
|
||||||
|
assert.match(css, /\.editor-media-preview/);
|
||||||
|
assert.match(css, /\.editor-media-remove/);
|
||||||
|
assert.match(css, /\.editor-danger-button/);
|
||||||
|
assert.match(css, /\.editor-icon-danger/);
|
||||||
|
assert.match(css, /\.route-control-button img/);
|
||||||
|
assert.doesNotMatch(css, /\.editor-button-icon/);
|
||||||
|
|
||||||
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
|
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
|
||||||
assert.match(ui, /ns\.LocalRoutes\.save\(/);
|
assert.match(ui, /ns\.LocalRoutes\.save\(/);
|
||||||
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
|
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
|
||||||
assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
|
assert.match(ui, /ns\.LocalRoutes\.storeMediaFile\(/);
|
||||||
|
assert.match(ui, /showOpenFilePicker/);
|
||||||
|
assert.match(ui, /requestPermission:\s*true/);
|
||||||
|
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
|
||||||
assert.match(ui, /move-poi-up/);
|
assert.match(ui, /move-poi-up/);
|
||||||
assert.match(ui, /move-poi-down/);
|
assert.match(ui, /move-poi-down/);
|
||||||
|
assert.match(ui, /delete-route/);
|
||||||
|
assert.match(ui, /delete-poi/);
|
||||||
assert.match(ui, /sortOrder:\s*index/);
|
assert.match(ui, /sortOrder:\s*index/);
|
||||||
assert.match(ui, /pointerup/);
|
assert.match(ui, /pointerup/);
|
||||||
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
|
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
|
||||||
assert.doesNotMatch(ui, /activated_at['"],\s*['"]/);
|
assert.doesNotMatch(ui, /activated_at['"],\s*['"]/);
|
||||||
assert.doesNotMatch(ui, /activatedAt:\s*null/);
|
assert.doesNotMatch(ui, /activatedAt:\s*null/);
|
||||||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||||||
|
assert.match(ui, /\/pictures/);
|
||||||
|
assert.match(ui, /'picture'/);
|
||||||
|
assert.match(ui, /'audio'/);
|
||||||
|
assert.match(ui, /method:\s*'DELETE'/);
|
||||||
|
assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
|
||||||
|
assert.match(ui, /serverRouteId/);
|
||||||
|
assert.match(ui, /position\?\.accuracy/);
|
||||||
|
assert.match(ui, /setHidden\(byId\(ids\.routeGpxGroup\), state\.currentSource !== 'server'\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
class FakeElement extends EventTarget {
|
class FakeElement extends EventTarget {
|
||||||
@@ -64,6 +99,7 @@ class FakeElement extends EventTarget {
|
|||||||
this.title = '';
|
this.title = '';
|
||||||
this.type = '';
|
this.type = '';
|
||||||
this._textContent = '';
|
this._textContent = '';
|
||||||
|
this.clickCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get textContent() {
|
get textContent() {
|
||||||
@@ -96,6 +132,10 @@ class FakeElement extends EventTarget {
|
|||||||
if (name === 'open') this.open = false;
|
if (name === 'open') this.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.clickCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
showModal() {
|
showModal() {
|
||||||
this.open = true;
|
this.open = true;
|
||||||
}
|
}
|
||||||
@@ -117,8 +157,8 @@ class FakeFormData {
|
|||||||
this.entries = [];
|
this.entries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
append(name, value) {
|
append(name, value, filename) {
|
||||||
this.entries.push([name, value]);
|
this.entries.push([name, value, filename]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +181,6 @@ function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrim
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keydownEvent(key, target = null) {
|
|
||||||
const event = new Event('keydown', { cancelable: true });
|
|
||||||
Object.defineProperty(event, 'key', { value: key });
|
|
||||||
if (target) Object.defineProperty(event, 'target', { value: target });
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitEvent() {
|
function submitEvent() {
|
||||||
return new Event('submit', { cancelable: true });
|
return new Event('submit', { cancelable: true });
|
||||||
}
|
}
|
||||||
@@ -160,16 +193,21 @@ function buildEditorContext() {
|
|||||||
'editor-dialog',
|
'editor-dialog',
|
||||||
'editor-new-route',
|
'editor-new-route',
|
||||||
'editor-poi-audio',
|
'editor-poi-audio',
|
||||||
|
'editor-poi-audio-add',
|
||||||
'editor-poi-cancel',
|
'editor-poi-cancel',
|
||||||
|
'editor-poi-delete',
|
||||||
'editor-poi-description',
|
'editor-poi-description',
|
||||||
'editor-poi-form',
|
'editor-poi-form',
|
||||||
'editor-poi-form-title',
|
'editor-poi-form-title',
|
||||||
'editor-poi-form-view',
|
'editor-poi-form-view',
|
||||||
'editor-poi-id',
|
'editor-poi-id',
|
||||||
'editor-poi-images',
|
'editor-poi-images',
|
||||||
|
'editor-poi-images-add',
|
||||||
'editor-poi-lat',
|
'editor-poi-lat',
|
||||||
'editor-poi-list',
|
'editor-poi-list',
|
||||||
'editor-poi-lon',
|
'editor-poi-lon',
|
||||||
|
'editor-poi-media-preview',
|
||||||
|
'editor-poi-media-status',
|
||||||
'editor-poi-radius',
|
'editor-poi-radius',
|
||||||
'editor-poi-title',
|
'editor-poi-title',
|
||||||
'editor-route-activated',
|
'editor-route-activated',
|
||||||
@@ -178,10 +216,12 @@ function buildEditorContext() {
|
|||||||
'editor-route-detail-view',
|
'editor-route-detail-view',
|
||||||
'editor-route-form',
|
'editor-route-form',
|
||||||
'editor-route-gpx',
|
'editor-route-gpx',
|
||||||
|
'editor-route-gpx-group',
|
||||||
'editor-route-id',
|
'editor-route-id',
|
||||||
'editor-route-list',
|
'editor-route-list',
|
||||||
'editor-route-list-view',
|
'editor-route-list-view',
|
||||||
'editor-route-name',
|
'editor-route-name',
|
||||||
|
'editor-route-upload',
|
||||||
'editor-status'
|
'editor-status'
|
||||||
];
|
];
|
||||||
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
||||||
@@ -197,10 +237,7 @@ function buildEditorContext() {
|
|||||||
id: 'local-existing',
|
id: 'local-existing',
|
||||||
gpx: '<gpx/>',
|
gpx: '<gpx/>',
|
||||||
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
|
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
|
||||||
pois: [
|
pois: []
|
||||||
{ id: 21, title: 'Lokaler Start', description: 'Start', lat: 50.2, lon: 10.2, triggerRadiusM: 25, sequence: 0 },
|
|
||||||
{ id: 22, title: 'Lokales Ziel', description: 'Ziel', lat: 50.3, lon: 10.3, triggerRadiusM: 25, sequence: 1 }
|
|
||||||
]
|
|
||||||
}]
|
}]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -209,35 +246,41 @@ function buildEditorContext() {
|
|||||||
name: 'Freigegebener Waldweg',
|
name: 'Freigegebener Waldweg',
|
||||||
description: 'Serverroute',
|
description: 'Serverroute',
|
||||||
activatedAt: '2026-09-01 08:00:00',
|
activatedAt: '2026-09-01 08:00:00',
|
||||||
pois: [
|
pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }]
|
||||||
{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 },
|
|
||||||
{ id: 11, title: 'Ziel', description: 'Ankommen', lat: 50.2, lon: 10.2, triggerRadiusM: 20, sequence: 1 }
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetch(url, options = {}) {
|
async function fetch(url, options = {}) {
|
||||||
const method = options.method || 'GET';
|
const method = options.method || 'GET';
|
||||||
calls.push({ body: options.body, method, url });
|
calls.push({ body: options.body, method, url });
|
||||||
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
|
if (String(url).startsWith('data:')) {
|
||||||
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
|
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
|
||||||
if (url === '/api/routes/1' && method === 'PUT') {
|
|
||||||
if (options.body instanceof FakeFormData) {
|
|
||||||
const values = Object.fromEntries(options.body.entries);
|
|
||||||
return jsonResponse({ route: { ...serverRoute, name: values.name, description: values.description } });
|
|
||||||
}
|
}
|
||||||
|
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
|
||||||
|
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
|
||||||
|
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
|
||||||
|
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201);
|
||||||
|
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||||
|
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
|
||||||
|
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||||
|
if (url === '/api/routes/1' && method === 'PUT') {
|
||||||
const body = JSON.parse(options.body);
|
const body = JSON.parse(options.body);
|
||||||
return jsonResponse({ route: { ...serverRoute, ...body } });
|
return jsonResponse({ route: { ...serverRoute, ...body } });
|
||||||
}
|
}
|
||||||
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
|
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
|
||||||
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
|
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
|
||||||
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true });
|
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ ok: true });
|
||||||
|
if (/^\/api\/routes\/1\/pois\/\d+\/audio$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||||
|
if (/^\/api\/routes\/1\/pois\/\d+\/pictures\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||||
|
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||||||
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
|
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaBlobs = new Map();
|
||||||
|
const storedMediaCalls = [];
|
||||||
const LocalRoutes = {
|
const LocalRoutes = {
|
||||||
createPoiId: () => 9001,
|
createPoiId: () => 9001,
|
||||||
createRouteId: () => 'local-new',
|
createRouteId: () => 'local-new',
|
||||||
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata></gpx>`,
|
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
|
||||||
fileToDataUrl: async file => ({
|
fileToDataUrl: async file => ({
|
||||||
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
|
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
@@ -245,17 +288,50 @@ function buildEditorContext() {
|
|||||||
type: file.type || 'application/octet-stream'
|
type: file.type || 'application/octet-stream'
|
||||||
}),
|
}),
|
||||||
get: id => packages.get(String(id)) || null,
|
get: id => packages.get(String(id)) || null,
|
||||||
|
getWithMedia: async id => {
|
||||||
|
const value = packages.get(String(id));
|
||||||
|
if (!value) return null;
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
pois: value.pois.map(poi => ({
|
||||||
|
...poi,
|
||||||
|
audio: poi.audio ? { ...poi.audio, url: 'blob:test-audio' } : null,
|
||||||
|
images: (poi.images || []).map(image => ({ ...image, url: 'blob:test-image' }))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
gpxForUpload: routePackage => routePackage.gpx,
|
||||||
list: () => [...packages.values()],
|
list: () => [...packages.values()],
|
||||||
parseWaypoints: () => [],
|
parseWaypoints: () => [],
|
||||||
readFileAsText: file => file.text(),
|
readFileAsText: file => file.text(),
|
||||||
|
mediaBlob: async media => mediaBlobs.get(media.mediaKey) || new Blob(['media'], { type: media.type || 'application/octet-stream' }),
|
||||||
|
removeMedia: async media => { if (media?.mediaKey) mediaBlobs.delete(media.mediaKey); },
|
||||||
|
remove: async id => packages.delete(String(id)),
|
||||||
|
storeMediaFile: async (routeId, poiId, kind, mediaId, file, handle = null) => {
|
||||||
|
storedMediaCalls.push({ routeId, poiId, kind, mediaId, file, handle });
|
||||||
|
const mediaKey = `${routeId}:${poiId}:${kind}:${mediaId}`;
|
||||||
|
mediaBlobs.set(mediaKey, file);
|
||||||
|
return {
|
||||||
|
id: String(mediaId),
|
||||||
|
mediaKey,
|
||||||
|
storage: handle ? 'file-handle' : 'indexeddb',
|
||||||
|
name: file.name,
|
||||||
|
size: file.size || 1,
|
||||||
|
type: file.type || 'application/octet-stream'
|
||||||
|
};
|
||||||
|
},
|
||||||
save: input => {
|
save: input => {
|
||||||
const id = String(input.id);
|
const id = String(input.id);
|
||||||
const now = '2026-09-19T14:00:00.000Z';
|
const now = '2026-09-19T14:00:00.000Z';
|
||||||
|
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
|
||||||
|
const gpxPoints = pois
|
||||||
|
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
|
||||||
|
.join('');
|
||||||
const saved = {
|
const saved = {
|
||||||
id,
|
id,
|
||||||
gpx: input.gpx,
|
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`,
|
||||||
route: { ...input.route, id, updatedAt: now },
|
route: { ...input.route, id, updatedAt: now },
|
||||||
pois: input.pois.map((poi, sequence) => ({ ...poi, sequence }))
|
pois
|
||||||
};
|
};
|
||||||
packages.set(id, saved);
|
packages.set(id, saved);
|
||||||
saves.push(structuredClone(saved));
|
saves.push(structuredClone(saved));
|
||||||
@@ -264,6 +340,7 @@ function buildEditorContext() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
Blob,
|
||||||
document,
|
document,
|
||||||
Event,
|
Event,
|
||||||
EventTarget,
|
EventTarget,
|
||||||
@@ -271,17 +348,18 @@ function buildEditorContext() {
|
|||||||
FormData: FakeFormData,
|
FormData: FakeFormData,
|
||||||
globalThis: null,
|
globalThis: null,
|
||||||
structuredClone,
|
structuredClone,
|
||||||
|
URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} },
|
||||||
Wegwichtel: {
|
Wegwichtel: {
|
||||||
App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
|
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
|
||||||
Config: { apiBase: '/api/' },
|
Config: { apiBase: '/api/' },
|
||||||
LocalRoutes
|
LocalRoutes
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
context.globalThis = context;
|
context.globalThis = context;
|
||||||
return { calls, context, elements, packages, saves, serverRoute };
|
return { calls, context, elements, packages, saves, storedMediaCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('public editor workflow saves new route and GPX POI locally without POSTing a route', async () => {
|
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => {
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const { calls, context, elements, packages, saves } = buildEditorContext();
|
const { calls, context, elements, packages, saves } = buildEditorContext();
|
||||||
@@ -294,6 +372,7 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
|
|||||||
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
||||||
|
|
||||||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||||
|
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
|
||||||
elements.get('editor-route-name').value = 'Neue lokale Strecke';
|
elements.get('editor-route-name').value = 'Neue lokale Strecke';
|
||||||
elements.get('editor-route-description').value = 'Nur im Browser';
|
elements.get('editor-route-description').value = 'Nur im Browser';
|
||||||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
@@ -304,34 +383,191 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
|
|||||||
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
|
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
|
||||||
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
|
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
|
||||||
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
|
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
|
||||||
|
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
|
||||||
|
assert.equal(elements.get('editor-route-upload').hidden, false);
|
||||||
|
assert.equal(elements.get('editor-route-upload').disabled, false);
|
||||||
|
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
|
||||||
|
|
||||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
|
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
|
||||||
|
assert.equal(elements.get('editor-poi-images').clickCount, 0);
|
||||||
|
elements.get('editor-poi-audio-add').dispatchEvent(pointerEvent());
|
||||||
|
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
|
||||||
|
assert.equal(elements.get('editor-poi-audio').clickCount, 1);
|
||||||
|
assert.equal(elements.get('editor-poi-images').clickCount, 1);
|
||||||
|
assert.equal(elements.get('editor-poi-lat').value, '50.123456789');
|
||||||
|
assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
|
||||||
|
assert.equal(elements.get('editor-poi-radius').value, '4.321');
|
||||||
elements.get('editor-poi-title').value = 'Aussicht';
|
elements.get('editor-poi-title').value = 'Aussicht';
|
||||||
elements.get('editor-poi-description').value = 'Text zur Station';
|
elements.get('editor-poi-description').value = 'Text zum Streckenpunkt';
|
||||||
elements.get('editor-poi-lat').value = '50.51';
|
|
||||||
elements.get('editor-poi-lon').value = '10.52';
|
|
||||||
elements.get('editor-poi-radius').value = '35';
|
|
||||||
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
|
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
|
||||||
|
elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
|
||||||
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
|
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
|
||||||
|
elements.get('editor-poi-images').dispatchEvent(new Event('change'));
|
||||||
|
await tick();
|
||||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
const local = packages.get('local-new');
|
let local = packages.get('local-new');
|
||||||
assert.equal(local.pois.length, 1);
|
assert.equal(local.pois.length, 1);
|
||||||
assert.equal(local.pois[0].title, 'Aussicht');
|
assert.equal(local.pois[0].title, 'Aussicht');
|
||||||
assert.equal(local.pois[0].lat, 50.51);
|
assert.equal(local.pois[0].lat, 50.123456789);
|
||||||
assert.equal(local.pois[0].lon, 10.52);
|
assert.equal(local.pois[0].lon, 10.987654321);
|
||||||
assert.equal(local.pois[0].triggerRadiusM, 35);
|
assert.equal(local.pois[0].triggerRadiusM, 4.321);
|
||||||
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
|
assert.equal(local.pois[0].audio.storage, 'indexeddb');
|
||||||
|
assert.ok(local.pois[0].audio.mediaKey);
|
||||||
assert.equal(local.pois[0].images.length, 1);
|
assert.equal(local.pois[0].images.length, 1);
|
||||||
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
|
assert.equal(local.pois[0].images[0].storage, 'indexeddb');
|
||||||
|
assert.ok(local.pois[0].images[0].mediaKey);
|
||||||
|
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
|
||||||
|
|
||||||
|
const pointListBeforeUpload = elements.get('editor-poi-list');
|
||||||
|
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
|
||||||
|
assert.equal(pointItemBeforeUpload.children[1].children[3].textContent, '×');
|
||||||
|
assert.match(pointItemBeforeUpload.children[1].children[3].className, /editor-icon-danger/);
|
||||||
|
|
||||||
|
elements.get('editor-route-name').value = '--- Neue lokale Strecke ---';
|
||||||
|
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const routeUpload = calls.find(call => call.method === 'POST' && call.url === '/api/routes');
|
||||||
|
assert.ok(routeUpload);
|
||||||
|
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
|
||||||
|
assert.ok(gpxUpload);
|
||||||
|
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
|
||||||
|
assert.match(await gpxUpload[1].text(), /<trkpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
|
||||||
|
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
|
||||||
|
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
|
||||||
|
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
|
||||||
|
local = packages.get('local-new');
|
||||||
|
assert.equal(local.route.serverRouteId, '22');
|
||||||
|
assert.ok(local.route.uploadedAt);
|
||||||
|
assert.equal(elements.get('editor-route-upload').disabled, true);
|
||||||
|
assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen');
|
||||||
|
assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.');
|
||||||
|
|
||||||
|
const poiList = elements.get('editor-poi-list');
|
||||||
|
const poiItem = poiList.children.find(child => child.dataset.poiId);
|
||||||
|
const poiEditButton = poiItem.children[1].children[2];
|
||||||
|
poiList.dispatchEvent(pointerEvent(poiEditButton));
|
||||||
|
const mediaPreview = elements.get('editor-poi-media-preview');
|
||||||
|
assert.equal(mediaPreview.children.length, 2);
|
||||||
|
|
||||||
|
let imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
|
||||||
|
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[0]));
|
||||||
|
imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
|
||||||
|
assert.equal(imageCard.children[1].hidden, false);
|
||||||
|
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[1]));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
local = packages.get('local-new');
|
||||||
|
assert.equal(local.pois[0].images.length, 0);
|
||||||
|
|
||||||
|
let audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
|
||||||
|
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[0]));
|
||||||
|
audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
|
||||||
|
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[1]));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
local = packages.get('local-new');
|
||||||
|
assert.equal(local.pois[0].audio, null);
|
||||||
|
|
||||||
|
assert.equal(elements.get('editor-poi-delete').hidden, false);
|
||||||
|
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
local = packages.get('local-new');
|
||||||
|
assert.equal(local.pois.length, 0);
|
||||||
|
|
||||||
context.Wegwichtel.EditorUi.close();
|
context.Wegwichtel.EditorUi.close();
|
||||||
assert.equal(elements.get('editor-dialog').open, false);
|
assert.equal(elements.get('editor-dialog').open, false);
|
||||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local Streckenpunkt ids start at zero and increment from the highest existing id', async () => {
|
||||||
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
|
const { context, elements, packages } = buildEditorContext();
|
||||||
|
|
||||||
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
|
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||||
|
elements.get('editor-route-name').value = 'ID-Test';
|
||||||
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
|
assert.equal(elements.get('editor-poi-id').value, '0');
|
||||||
|
elements.get('editor-poi-title').value = 'Erster Streckenpunkt';
|
||||||
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
|
assert.equal(elements.get('editor-poi-id').value, '1');
|
||||||
|
elements.get('editor-poi-title').value = 'Zweiter Streckenpunkt';
|
||||||
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const saved = packages.get('local-new');
|
||||||
|
assert.equal(saved.pois.length, 2);
|
||||||
|
assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]);
|
||||||
|
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Streckenpunkt', 'Zweiter Streckenpunkt']);
|
||||||
|
|
||||||
|
const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId);
|
||||||
|
assert.equal(pointItems.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
|
||||||
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
|
const { context, elements, packages } = buildEditorContext();
|
||||||
|
|
||||||
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
|
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||||
|
elements.get('editor-route-name').value = 'Löschtest';
|
||||||
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
|
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
|
||||||
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
let pointList = elements.get('editor-poi-list');
|
||||||
|
let pointItem = pointList.children.find(child => child.dataset.poiId);
|
||||||
|
const pointDelete = pointItem.children[1].children[3];
|
||||||
|
assert.equal(pointDelete.textContent, '×');
|
||||||
|
assert.match(pointDelete.className, /editor-icon-danger/);
|
||||||
|
pointList.dispatchEvent(pointerEvent(pointDelete));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
assert.equal(packages.get('local-new').pois.length, 0);
|
||||||
|
|
||||||
|
elements.get('editor-route-back').dispatchEvent(pointerEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
const routeList = elements.get('editor-route-list');
|
||||||
|
const localItem = routeList.children.find(child => child.dataset.routeId === 'local-new');
|
||||||
|
const routeDelete = localItem.children[1].children[1];
|
||||||
|
assert.equal(routeDelete.textContent, '×');
|
||||||
|
assert.match(routeDelete.className, /editor-icon-danger/);
|
||||||
|
routeList.dispatchEvent(pointerEvent(routeDelete));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
assert.equal(packages.has('local-new'), false);
|
||||||
|
assert.equal(elements.get('editor-status').textContent, 'Strecke gelöscht.');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
test('editing an activated server route uses PUT and never writes activation state', async () => {
|
test('editing an activated server route uses PUT and never writes activation state', async () => {
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
@@ -342,13 +578,27 @@ test('editing an activated server route uses PUT and never writes activation sta
|
|||||||
|
|
||||||
const routeList = elements.get('editor-route-list');
|
const routeList = elements.get('editor-route-list');
|
||||||
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
||||||
const editButton = serverItem.children[1].children[0];
|
const serverActions = serverItem.children[1];
|
||||||
|
assert.equal(serverActions.children.length, 1);
|
||||||
|
assert.equal(serverActions.children[0].dataset.editorAction, 'edit-route');
|
||||||
|
|
||||||
|
const forgedDelete = new FakeElement('', 'button');
|
||||||
|
forgedDelete.dataset.editorAction = 'delete-route';
|
||||||
|
serverActions.appendChild(forgedDelete);
|
||||||
|
routeList.dispatchEvent(pointerEvent(forgedDelete));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
assert.equal(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/1'), false);
|
||||||
|
|
||||||
|
const editButton = serverActions.children[0];
|
||||||
routeList.dispatchEvent(pointerEvent(editButton));
|
routeList.dispatchEvent(pointerEvent(editButton));
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
assert.equal(elements.get('editor-route-id').value, '1');
|
assert.equal(elements.get('editor-route-id').value, '1');
|
||||||
|
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
|
||||||
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
|
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
|
||||||
|
assert.equal(elements.get('editor-route-upload').hidden, true);
|
||||||
|
|
||||||
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
|
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
|
||||||
elements.get('editor-route-description').value = 'Bearbeitet';
|
elements.get('editor-route-description').value = 'Bearbeitet';
|
||||||
@@ -362,200 +612,42 @@ test('editing an activated server route uses PUT and never writes activation sta
|
|||||||
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
|
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
|
||||||
assert.equal('activated_at' in body, false);
|
assert.equal('activated_at' in body, false);
|
||||||
assert.equal('activatedAt' in body, false);
|
assert.equal('activatedAt' in body, false);
|
||||||
|
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('editor supports keyboard activation and the dialog fallback without native dialog methods', async () => {
|
test('local media picker keeps File System Access handles for later upload', async () => {
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const { context, elements } = buildEditorContext();
|
const { context, elements, storedMediaCalls } = buildEditorContext();
|
||||||
const dialog = elements.get('editor-dialog');
|
const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
|
||||||
dialog.showModal = undefined;
|
const handle = {
|
||||||
dialog.close = undefined;
|
name: 'foto.jpg',
|
||||||
|
async getFile() { return imageFile; }
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
};
|
||||||
|
context.showOpenFilePicker = async options => {
|
||||||
elements.get('editor-button').dispatchEvent(keydownEvent('Enter'));
|
assert.equal(options.multiple, true);
|
||||||
await tick();
|
return [handle];
|
||||||
await tick();
|
};
|
||||||
|
|
||||||
assert.equal(dialog.open, true);
|
|
||||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
|
|
||||||
|
|
||||||
elements.get('editor-close').dispatchEvent(pointerEvent());
|
|
||||||
assert.equal(dialog.open, false);
|
|
||||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('server route GPX upload uses multipart form data and keeps activation state untouched', async () => {
|
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
|
||||||
const { calls, context, elements } = buildEditorContext();
|
|
||||||
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
|
||||||
|
|
||||||
const serverItem = elements.get('editor-route-list').children.find(child => child.dataset.routeSource === 'server');
|
|
||||||
const editButton = serverItem.children[1].children[0];
|
|
||||||
elements.get('editor-route-list').dispatchEvent(pointerEvent(editButton));
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
elements.get('editor-route-name').value = 'Serverroute mit GPX';
|
|
||||||
elements.get('editor-route-description').value = 'Multipart';
|
|
||||||
elements.get('editor-route-gpx').files = [{ name: 'route.gpx', type: 'application/gpx+xml' }];
|
|
||||||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData);
|
|
||||||
assert.ok(update);
|
|
||||||
assert.deepEqual(update.body.entries.map(([name]) => name), ['name', 'description', 'file']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('server POI workflow creates media, edits POIs and persists reordered sequence values', async () => {
|
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
|
||||||
const { calls, context, elements } = buildEditorContext();
|
|
||||||
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
|
||||||
|
|
||||||
const routeList = elements.get('editor-route-list');
|
|
||||||
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
|
||||||
routeList.dispatchEvent(pointerEvent(serverItem.children[1].children[0]));
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
|
||||||
assert.equal(elements.get('editor-poi-lat').value, '50.5');
|
|
||||||
assert.equal(elements.get('editor-poi-lon').value, '10.5');
|
|
||||||
|
|
||||||
elements.get('editor-poi-title').value = 'Neue Serverstation';
|
|
||||||
elements.get('editor-poi-description').value = 'Mit Medien';
|
|
||||||
elements.get('editor-poi-audio').files = [{ name: 'station.mp3', type: 'audio/mpeg' }];
|
|
||||||
elements.get('editor-poi-images').files = [
|
|
||||||
{ name: 'eins.jpg', type: 'image/jpeg' },
|
|
||||||
{ name: 'zwei.jpg', type: 'image/jpeg' }
|
|
||||||
];
|
|
||||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois'));
|
|
||||||
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/audio'));
|
|
||||||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/images').length, 2);
|
|
||||||
|
|
||||||
const poiList = elements.get('editor-poi-list');
|
|
||||||
const firstPoi = poiList.children.find(child => child.dataset.poiId === '10');
|
|
||||||
const secondPoi = poiList.children.find(child => child.dataset.poiId === '11');
|
|
||||||
|
|
||||||
poiList.dispatchEvent(pointerEvent(secondPoi.children[1].children[2]));
|
|
||||||
assert.equal(elements.get('editor-poi-id').value, '11');
|
|
||||||
assert.equal(elements.get('editor-poi-title').value, 'Ziel');
|
|
||||||
|
|
||||||
elements.get('editor-poi-title').value = 'Ziel geändert';
|
|
||||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/11'));
|
|
||||||
|
|
||||||
const refreshedFirstPoi = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '10');
|
|
||||||
elements.get('editor-poi-list').dispatchEvent(pointerEvent(refreshedFirstPoi.children[1].children[1]));
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const reorderCalls = calls.filter(call => call.method === 'PUT' && /^\/api\/routes\/1\/pois\/(10|11)$/.test(call.url));
|
|
||||||
assert.ok(reorderCalls.length >= 2);
|
|
||||||
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 0));
|
|
||||||
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 1));
|
|
||||||
|
|
||||||
// Moving the first element upward is a no-op and must not trigger another request.
|
|
||||||
const countBeforeNoop = calls.length;
|
|
||||||
const firstAfterReorder = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '11');
|
|
||||||
elements.get('editor-poi-list').dispatchEvent(pointerEvent(firstAfterReorder.children[1].children[0]));
|
|
||||||
await tick();
|
|
||||||
assert.equal(calls.length, countBeforeNoop);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('local route import, local reordering and validation errors remain browser-only', async () => {
|
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
|
||||||
const { calls, context, elements, packages, saves } = buildEditorContext();
|
|
||||||
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||||
|
elements.get('editor-route-name').value = 'Handle-Strecke';
|
||||||
context.Wegwichtel.LocalRoutes.parseWaypoints = () => [
|
|
||||||
{ id: 700, title: 'Importiert', description: '', lat: 50.7, lon: 10.7, triggerRadiusM: 25, sequence: 0, images: [], audio: null }
|
|
||||||
];
|
|
||||||
elements.get('editor-route-name').value = 'Importierte Strecke';
|
|
||||||
elements.get('editor-route-gpx').files = [{ text: async () => '<gpx><wpt/></gpx>' }];
|
|
||||||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
assert.equal(packages.get('local-new').pois[0].id, 700);
|
|
||||||
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
|
|
||||||
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
|
||||||
const localItem = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing');
|
|
||||||
elements.get('editor-route-list').dispatchEvent(pointerEvent(localItem.children[1].children[0]));
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const localFirst = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '21');
|
|
||||||
elements.get('editor-poi-list').dispatchEvent(pointerEvent(localFirst.children[1].children[1]));
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
assert.deepEqual(saves.at(-1).pois.map(poi => poi.id), [22, 21]);
|
|
||||||
|
|
||||||
context.Wegwichtel.App.currentPosition = () => null;
|
|
||||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
elements.get('editor-poi-title').value = 'Ungültige Position';
|
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild';
|
||||||
elements.get('editor-poi-lat').value = 'keine-breite';
|
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
|
||||||
elements.get('editor-poi-lon').value = 'keine-laenge';
|
await tick();
|
||||||
|
assert.equal(elements.get('editor-poi-images').clickCount, 0);
|
||||||
|
|
||||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
assert.equal(elements.get('editor-status').dataset.kind, 'error');
|
const imageStore = storedMediaCalls.find(call => call.kind === 'image');
|
||||||
assert.match(elements.get('editor-status').textContent, /gültige Breiten- und Längengrade/);
|
assert.ok(imageStore);
|
||||||
});
|
assert.equal(imageStore.handle, handle);
|
||||||
|
assert.equal(imageStore.file, imageFile);
|
||||||
test('editor handles empty, malformed and failing route responses without stale status', async () => {
|
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
|
||||||
|
|
||||||
{
|
|
||||||
const { context, elements } = buildEditorContext();
|
|
||||||
context.Wegwichtel.LocalRoutes.list = () => [];
|
|
||||||
context.fetch = async () => ({ ok: true, status: 200, text: async () => '{' });
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
|
||||||
const emptyItems = elements.get('editor-route-list').children.filter(child => !child.dataset.routeId);
|
|
||||||
assert.ok(emptyItems.length >= 4);
|
|
||||||
assert.equal(elements.get('editor-status').hidden, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { context, elements } = buildEditorContext();
|
|
||||||
context.Wegwichtel.LocalRoutes.list = () => { throw new Error('Lokaler Speicher defekt'); };
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
|
||||||
assert.equal(elements.get('editor-status').dataset.kind, 'error');
|
|
||||||
assert.match(elements.get('editor-status').textContent, /Lokaler Speicher defekt/);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { context, elements } = buildEditorContext();
|
|
||||||
context.fetch = async () => ({ ok: false, status: 503, text: async () => 'Dienst nicht verfügbar' });
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
||||||
elements.get('editor-button').dispatchEvent(pointerEvent());
|
|
||||||
await tick();
|
|
||||||
await tick();
|
|
||||||
assert.equal(elements.get('editor-status').dataset.kind, 'error');
|
|
||||||
assert.match(elements.get('editor-status').textContent, /Dienst nicht verfügbar/);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
+224
-60
@@ -217,6 +217,83 @@ class FakeStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function fakeIdbRequest(operation) {
|
||||||
|
const request = { result: null, error: null, onsuccess: null, onerror: null };
|
||||||
|
queueMicrotask(() => {
|
||||||
|
try {
|
||||||
|
request.result = operation();
|
||||||
|
request.onsuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
request.error = error;
|
||||||
|
request.onerror?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeIndexedDatabaseConnection {
|
||||||
|
constructor(records) {
|
||||||
|
this.records = records;
|
||||||
|
this.objectStoreNames = { contains: name => name === 'media' };
|
||||||
|
}
|
||||||
|
|
||||||
|
createObjectStore() {
|
||||||
|
this.objectStoreNames = { contains: name => name === 'media' };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction() {
|
||||||
|
const records = this.records;
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
onabort: null,
|
||||||
|
objectStore() {
|
||||||
|
return {
|
||||||
|
put(record) {
|
||||||
|
return fakeIdbRequest(() => {
|
||||||
|
records.set(record.key, record);
|
||||||
|
return record.key;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
get(key) {
|
||||||
|
return fakeIdbRequest(() => records.get(key) || undefined);
|
||||||
|
},
|
||||||
|
delete(key) {
|
||||||
|
return fakeIdbRequest(() => {
|
||||||
|
records.delete(key);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeIndexedDB {
|
||||||
|
constructor() {
|
||||||
|
this.records = new Map();
|
||||||
|
this.database = new FakeIndexedDatabaseConnection(this.records);
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
const request = {
|
||||||
|
result: this.database,
|
||||||
|
error: null,
|
||||||
|
onupgradeneeded: null,
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
onblocked: null
|
||||||
|
};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
request.onupgradeneeded?.();
|
||||||
|
request.onsuccess?.();
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FakeCustomEvent extends Event {
|
class FakeCustomEvent extends Event {
|
||||||
constructor(type, options = {}) {
|
constructor(type, options = {}) {
|
||||||
super(type);
|
super(type);
|
||||||
@@ -234,11 +311,6 @@ class FakeFileReader extends EventTarget {
|
|||||||
|
|
||||||
readAsDataURL(file) {
|
readAsDataURL(file) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (file.failReader) {
|
|
||||||
this.error = new Error('reader failed');
|
|
||||||
this.dispatchEvent(new Event('error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const type = file.type || 'application/octet-stream';
|
const type = file.type || 'application/octet-stream';
|
||||||
this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`;
|
this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`;
|
||||||
this.dispatchEvent(new Event('load'));
|
this.dispatchEvent(new Event('load'));
|
||||||
@@ -250,11 +322,13 @@ async function loadLocalRoutes(options = {}) {
|
|||||||
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const localStorage = new FakeStorage();
|
const localStorage = new FakeStorage();
|
||||||
|
const indexedDB = new FakeIndexedDB();
|
||||||
const document = new EventTarget();
|
const document = new EventTarget();
|
||||||
const events = [];
|
const events = [];
|
||||||
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
|
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
Blob,
|
||||||
CustomEvent: FakeCustomEvent,
|
CustomEvent: FakeCustomEvent,
|
||||||
DOMParser: FakeDOMParser,
|
DOMParser: FakeDOMParser,
|
||||||
Event,
|
Event,
|
||||||
@@ -264,13 +338,15 @@ async function loadLocalRoutes(options = {}) {
|
|||||||
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
||||||
document,
|
document,
|
||||||
globalThis: null,
|
globalThis: null,
|
||||||
|
indexedDB,
|
||||||
localStorage,
|
localStorage,
|
||||||
queueMicrotask,
|
queueMicrotask,
|
||||||
|
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
|
||||||
Wegwichtel: {}
|
Wegwichtel: {}
|
||||||
};
|
};
|
||||||
context.globalThis = context;
|
context.globalThis = context;
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
|
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -329,8 +405,14 @@ test('local route package stores GPX and JSON separately and reconstructs an app
|
|||||||
|
|
||||||
const saved = api.save({
|
const saved = api.save({
|
||||||
id: routeId,
|
id: routeId,
|
||||||
gpx: sampleGpx,
|
gpx: api.emptyGpx('Neue Route'),
|
||||||
route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' },
|
route: {
|
||||||
|
id: routeId,
|
||||||
|
name: 'Neue Route',
|
||||||
|
description: 'Lokal gespeichert',
|
||||||
|
serverRouteId: 42,
|
||||||
|
uploadedAt: '2026-09-27T18:00:00.000Z'
|
||||||
|
},
|
||||||
pois: [
|
pois: [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
@@ -364,15 +446,18 @@ test('local route package stores GPX and JSON separately and reconstructs an app
|
|||||||
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
|
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
|
||||||
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
|
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
|
||||||
|
|
||||||
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
|
assert.match(gpx, /<trkpt lat="50\.1" lon="10\.1">/);
|
||||||
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
||||||
assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), 'GPX waypoints must be serialized before the track');
|
assert.doesNotMatch(gpx, /<wpt/);
|
||||||
|
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
|
||||||
assert.equal(metadata.route.name, 'Neue Route');
|
assert.equal(metadata.route.name, 'Neue Route');
|
||||||
|
assert.equal(metadata.route.serverRouteId, 42);
|
||||||
|
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
|
||||||
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
|
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
|
||||||
assert.equal('gpx' in metadata, false);
|
assert.equal('gpx' in metadata, false);
|
||||||
assert.equal('_source' in metadata.route, false);
|
assert.equal('_source' in metadata.route, false);
|
||||||
|
|
||||||
const appRoute = api.getAppRoute(routeId);
|
const appRoute = await api.getAppRoute(routeId);
|
||||||
assert.equal(appRoute.source, 'local');
|
assert.equal(appRoute.source, 'local');
|
||||||
assert.equal(appRoute.local, true);
|
assert.equal(appRoute.local, true);
|
||||||
assert.equal(appRoute.pois.length, 2);
|
assert.equal(appRoute.pois.length, 2);
|
||||||
@@ -380,7 +465,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
|
|||||||
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
|
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
|
||||||
assert.equal(appRoute.points.length, 2);
|
assert.equal(appRoute.points.length, 2);
|
||||||
assert.ok(appRoute.distanceM > 0);
|
assert.ok(appRoute.distanceM > 0);
|
||||||
assert.equal(Math.round(appRoute.elevationGainM), 12);
|
assert.equal(Math.round(appRoute.elevationGainM), 0);
|
||||||
const listed = api.listAppRoutes();
|
const listed = api.listAppRoutes();
|
||||||
assert.equal(listed.length, 1);
|
assert.equal(listed.length, 1);
|
||||||
assert.equal(listed[0].id, routeId);
|
assert.equal(listed[0].id, routeId);
|
||||||
@@ -402,10 +487,49 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
|
|||||||
|
|
||||||
const waypointOnly = api.syncGpx(
|
const waypointOnly = api.syncGpx(
|
||||||
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
|
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
|
||||||
{ name: 'Nur POI' },
|
{ name: 'Nur Streckenpunkt' },
|
||||||
[{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }]
|
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
|
||||||
);
|
);
|
||||||
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
|
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
|
||||||
|
assert.doesNotMatch(waypointOnly, /<wpt\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => {
|
||||||
|
const { api } = await loadLocalRoutes();
|
||||||
|
const saved = api.save({
|
||||||
|
id: 'local-upload',
|
||||||
|
gpx: api.emptyGpx('Lokale Strecke'),
|
||||||
|
route: { name: 'Lokale Strecke' },
|
||||||
|
pois: [
|
||||||
|
{ id: 1, title: 'Start', lat: 50.123456789, lon: 10.987654321, sequence: 0 },
|
||||||
|
{ id: 2, title: 'Ziel', lat: 50.223456789, lon: 11.087654321, sequence: 1 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(saved.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
|
||||||
|
assert.match(saved.gpx, /<trkpt lat="50\.223456789" lon="11\.087654321">/);
|
||||||
|
assert.match(saved.gpx, /<wegwichtel:poiId>1<\/wegwichtel:poiId>/);
|
||||||
|
assert.equal((saved.gpx.match(/<trkpt /g) || []).length, 2);
|
||||||
|
assert.doesNotMatch(saved.gpx, /<wpt\b/);
|
||||||
|
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => {
|
||||||
|
const { api } = await loadLocalRoutes();
|
||||||
|
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
|
||||||
|
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(synced, /<ele>100<\/ele>/);
|
||||||
|
assert.match(synced, /<ele>112<\/ele>/);
|
||||||
|
assert.match(synced, /<wegwichtel:poiId>200<\/wegwichtel:poiId>/);
|
||||||
|
assert.equal((synced.match(/<trkpt /g) || []).length, 3);
|
||||||
|
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
|
||||||
|
assert.throws(() => api.gpxForUpload({
|
||||||
|
gpx: api.emptyGpx('Leer'),
|
||||||
|
route: { name: 'Leer' },
|
||||||
|
pois: []
|
||||||
|
}), /noch keine Streckenpunkte im GPX-Track/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove updates index and dispatches a local-route change event', async () => {
|
test('remove updates index and dispatches a local-route change event', async () => {
|
||||||
@@ -418,11 +542,11 @@ test('remove updates index and dispatches a local-route change event', async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(api.list().length, 1);
|
assert.equal(api.list().length, 1);
|
||||||
api.remove('local-remove');
|
await api.remove('local-remove');
|
||||||
assert.equal(api.list().length, 0);
|
assert.equal(api.list().length, 0);
|
||||||
assert.equal(events.at(-1).action, 'removed');
|
assert.equal(events.at(-1).action, 'removed');
|
||||||
assert.equal(events.at(-1).id, 'local-remove');
|
assert.equal(events.at(-1).id, 'local-remove');
|
||||||
assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/);
|
await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
||||||
@@ -440,11 +564,40 @@ test('quota failure rolls back package writes and keeps the previous package rea
|
|||||||
gpx: sampleGpx,
|
gpx: sampleGpx,
|
||||||
route: { name: 'Nachher' },
|
route: { name: 'Nachher' },
|
||||||
pois: []
|
pois: []
|
||||||
}), /Browser-Speicher ist voll/);
|
}), /Speicher für Streckendaten ist voll/);
|
||||||
|
|
||||||
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local media uses IndexedDB so route JSON does not consume Local Storage quota', async () => {
|
||||||
|
const { api, indexedDB, localStorage } = await loadLocalRoutes();
|
||||||
|
const routeId = 'local-media';
|
||||||
|
const audioFile = Object.assign(new Blob(['audio-payload'], { type: 'audio/mpeg' }), { name: 'weg.mp3' });
|
||||||
|
const imageFile = Object.assign(new Blob(['image-payload'], { type: 'image/jpeg' }), { name: 'weg.jpg' });
|
||||||
|
|
||||||
|
const audio = await api.storeMediaFile(routeId, 1, 'audio', '1-audio', audioFile);
|
||||||
|
const image = await api.storeMediaFile(routeId, 1, 'image', '1-image', imageFile);
|
||||||
|
const saved = api.save({
|
||||||
|
id: routeId,
|
||||||
|
gpx: api.emptyGpx('Medientest'),
|
||||||
|
route: { name: 'Medientest' },
|
||||||
|
pois: [{ id: 1, title: 'Streckenpunkt 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
|
||||||
|
assert.doesNotMatch(metadata, /data:audio|data:image|audio-payload|image-payload/);
|
||||||
|
assert.match(metadata, /"storage":"indexeddb"/);
|
||||||
|
assert.equal(indexedDB.records.size, 2);
|
||||||
|
|
||||||
|
const hydrated = await api.getWithMedia(routeId);
|
||||||
|
assert.match(hydrated.pois[0].audio.url, /^blob:test-/);
|
||||||
|
assert.match(hydrated.pois[0].images[0].url, /^blob:test-/);
|
||||||
|
assert.equal((await api.mediaBlob(saved.pois[0].audio)).size, audioFile.size);
|
||||||
|
|
||||||
|
await api.remove(routeId);
|
||||||
|
assert.equal(indexedDB.records.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
|
test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
|
||||||
const { api } = await loadLocalRoutes();
|
const { api } = await loadLocalRoutes();
|
||||||
|
|
||||||
@@ -461,55 +614,66 @@ test('file helpers support Blob.text and FileReader-backed media data URLs', asy
|
|||||||
assert.equal(await api.fileToDataUrl(null), null);
|
assert.equal(await api.fileToDataUrl(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local media stores a File System Access handle instead of copying file bytes when supported', async () => {
|
||||||
|
const { api, indexedDB, localStorage } = await loadLocalRoutes();
|
||||||
|
const file = Object.assign(new Blob(['large-image'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
|
||||||
|
let permission = 'granted';
|
||||||
|
let permissionRequests = 0;
|
||||||
|
const handle = {
|
||||||
|
name: 'foto.jpg',
|
||||||
|
async getFile() { return file; },
|
||||||
|
async queryPermission() { return permission; },
|
||||||
|
async requestPermission() {
|
||||||
|
permissionRequests += 1;
|
||||||
|
permission = 'granted';
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
test('local route storage tolerates corrupt index and package JSON and generates unique POI ids', async () => {
|
const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle);
|
||||||
const { api, localStorage } = await loadLocalRoutes();
|
assert.equal(media.storage, 'file-handle');
|
||||||
const prefix = api.STORAGE_PREFIX;
|
const record = indexedDB.records.get(media.mediaKey);
|
||||||
|
assert.equal(record.handle, handle);
|
||||||
|
assert.equal('blob' in record, false);
|
||||||
|
|
||||||
localStorage.setItem(`${prefix}.index`, '{not-json');
|
api.save({
|
||||||
assert.deepEqual(Array.from(api.list()), []);
|
id: 'route-handle',
|
||||||
|
gpx: api.emptyGpx('Handle'),
|
||||||
|
route: { name: 'Handle' },
|
||||||
|
pois: [{ id: 7, title: 'Streckenpunkt', lat: 50.1, lon: 10.1, images: [media] }]
|
||||||
|
});
|
||||||
|
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
|
||||||
|
assert.match(metadata, /"storage":"file-handle"/);
|
||||||
|
assert.doesNotMatch(metadata, /large-image/);
|
||||||
|
|
||||||
const id = 'broken-package';
|
assert.equal(await api.mediaBlob(media), file);
|
||||||
const encoded = encodeURIComponent(id);
|
permission = 'prompt';
|
||||||
localStorage.setItem(`${prefix}.${encoded}.gpx`, api.emptyGpx('Defekt'));
|
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/);
|
||||||
localStorage.setItem(`${prefix}.${encoded}.json`, '{not-json');
|
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
|
||||||
assert.equal(api.get(id), null);
|
assert.equal(permissionRequests, 1);
|
||||||
|
|
||||||
const firstPoiId = api.createPoiId();
|
|
||||||
const secondPoiId = api.createPoiId();
|
|
||||||
assert.ok(Number.isSafeInteger(firstPoiId));
|
|
||||||
assert.ok(Number.isSafeInteger(secondPoiId));
|
|
||||||
assert.notEqual(firstPoiId, secondPoiId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('app route conversion supports routes without track points and default metadata', async () => {
|
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => {
|
||||||
const { api } = await loadLocalRoutes();
|
const { api, localStorage } = await loadLocalRoutes();
|
||||||
const route = api.toAppRoute({
|
const routeId = 'multi-media';
|
||||||
id: 'poi-only',
|
const large = new Uint8Array(1024 * 1024);
|
||||||
gpx: api.emptyGpx('Nur POIs'),
|
const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' });
|
||||||
route: {},
|
const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' });
|
||||||
|
const first = await api.storeMediaFile(routeId, 1, 'image', 'image-1', firstFile);
|
||||||
|
const second = await api.storeMediaFile(routeId, 2, 'image', 'image-2', secondFile);
|
||||||
|
|
||||||
|
api.save({
|
||||||
|
id: routeId,
|
||||||
|
gpx: api.emptyGpx('Mehrere Streckenpunkte'),
|
||||||
|
route: { name: 'Mehrere Streckenpunkte' },
|
||||||
pois: [
|
pois: [
|
||||||
{ id: 1, lat: 50, lon: 10, sequence: 0, images: [{ dataUrl: '' }, { dataUrl: 'data:image/png;base64,QQ==' }] },
|
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] },
|
||||||
{ id: 2, title: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 0 }
|
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(route.name, 'Unbenannte Strecke');
|
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
|
||||||
assert.equal(route.description, '');
|
assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`);
|
||||||
assert.equal(route.points.length, 2);
|
assert.doesNotMatch(metadata, /data:image/);
|
||||||
assert.equal(route.pois[0].title, 'Unbenannte Station');
|
assert.equal(api.get(routeId).pois.length, 2);
|
||||||
assert.equal(route.pois[0].triggerRadiusM, 25);
|
|
||||||
assert.equal(route.pois[0].images.length, 1);
|
|
||||||
assert.ok(route.distanceM > 0);
|
|
||||||
|
|
||||||
const empty = api.toAppRoute({ id: 'empty', gpx: api.emptyGpx('Leer'), route: {}, pois: [] });
|
|
||||||
assert.equal(empty.distanceM, 0);
|
|
||||||
assert.equal(empty.bounds, null);
|
|
||||||
assert.equal(empty.center, null);
|
|
||||||
assert.equal(empty.start, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media helper propagates FileReader errors', async () => {
|
|
||||||
const { api } = await loadLocalRoutes();
|
|
||||||
await assert.rejects(api.fileToDataUrl({ failReader: true }), /reader failed/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
|
||||||
|
async function nginxConfig() {
|
||||||
|
return fs.readFile(path.join(root, 'nginx/wegwichtel.conf'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeLines(config) {
|
||||||
|
return config
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('nginx listens actively on HTTP while SSL remains a commented template', async () => {
|
||||||
|
const config = await nginxConfig();
|
||||||
|
|
||||||
|
assert.match(config, /listen 80;\s*\n\s*listen \[::\]:80;\s*\n\s*# listen 443 ssl;\s*\n\s*# listen \[::\]:443 ssl;/);
|
||||||
|
assert.match(config, /server_name wegwichtel\.de www\.wegwichtel\.de;[\s\S]*# ssl_certificate\s+\/etc\/nginx\/ssl\/snakeoil\.pem;/);
|
||||||
|
assert.match(config, /# ssl_certificate_key\s+\/etc\/nginx\/ssl\/snakeoil\.key;/);
|
||||||
|
assert.match(config, /# ssl_protocols TLSv1\.2 TLSv1\.3;/);
|
||||||
|
|
||||||
|
const active = activeLines(config).join('\n');
|
||||||
|
assert.doesNotMatch(active, /listen (?:\[::\]:)?443\b/);
|
||||||
|
assert.doesNotMatch(active, /^ssl_/m);
|
||||||
|
assert.doesNotMatch(active, /https:\/\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nginx keeps reverse-proxy, upload and forwarding settings in the HTTP server block', async () => {
|
||||||
|
const config = await nginxConfig();
|
||||||
|
|
||||||
|
assert.match(config, /proxy_pass\s+http:\/\/127\.0\.0\.1:47145;/);
|
||||||
|
assert.match(config, /client_max_body_size\s+64m;/);
|
||||||
|
assert.match(config, /proxy_request_buffering\s+off;/);
|
||||||
|
assert.match(config, /proxy_set_header\s+Host\s+\$host;/);
|
||||||
|
assert.match(config, /proxy_set_header\s+X-Real-IP\s+\$remote_addr;/);
|
||||||
|
assert.match(config, /proxy_set_header\s+X-Forwarded-For\s+\$proxy_add_x_forwarded_for;/);
|
||||||
|
assert.match(config, /proxy_set_header\s+X-Forwarded-Proto\s+\$scheme;/);
|
||||||
|
assert.match(config, /proxy_connect_timeout\s+10s;/);
|
||||||
|
assert.match(config, /proxy_send_timeout\s+120s;/);
|
||||||
|
assert.match(config, /proxy_read_timeout\s+120s;/);
|
||||||
|
});
|
||||||
@@ -51,7 +51,7 @@ test('route selection separates activated server routes from local browser route
|
|||||||
assert.match(app, /\[data-route-select\]/);
|
assert.match(app, /\[data-route-select\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persistent route control displays the next unvisited POI and preserves progress when switching', async () => {
|
test('persistent route control displays the next unvisited Streckenpunkt and preserves progress when switching', async () => {
|
||||||
const app = await read('public/js/app.js');
|
const app = await read('public/js/app.js');
|
||||||
|
|
||||||
assert.match(app, /function nextPoiMetric\(/);
|
assert.match(app, /function nextPoiMetric\(/);
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ function assertHttpError(expectedStatus, expectedMessage) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeDeclaration(source, method, endpoint) {
|
||||||
|
const marker = `api.${method}('${endpoint}'`;
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
assert.notEqual(start, -1, `API route missing: ${method.toUpperCase()} ${endpoint}`);
|
||||||
|
const end = source.indexOf('\n', start);
|
||||||
|
return source.slice(start, end === -1 ? source.length : end);
|
||||||
|
}
|
||||||
|
|
||||||
test('nicht freigegebene Strecken bleiben schreibbar', () => {
|
test('nicht freigegebene Strecken bleiben schreibbar', () => {
|
||||||
const db = fakeDatabase({ id: 7, activatedAt: null });
|
const db = fakeDatabase({ id: 7, activatedAt: null });
|
||||||
assert.deepEqual(assertRouteWritable(db, 7), { id: 7, activatedAt: null });
|
assert.deepEqual(assertRouteWritable(db, 7), { id: 7, activatedAt: null });
|
||||||
@@ -110,42 +118,49 @@ test('DELETE einer freigegebenen Strecke ist nur direkt lokal zulässig', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('alle vorhandenen Schreibpfade verwenden die zentrale Freigabeprüfung', async () => {
|
test('alle mutierenden API-Pfade prüfen den Freigabestatus vor Upload oder Handler', async () => {
|
||||||
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
||||||
|
const writableRoutes = [
|
||||||
const writablePatterns = [
|
['put', '/routes/:id', 'id'],
|
||||||
/api\.put\('\/routes\/:id',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/,
|
['post', '/routes/:id/append', 'id'],
|
||||||
/api\.post\('\/routes\/:id\/append',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/,
|
['post', '/routes/:id/pois', 'id'],
|
||||||
/api\.post\('\/routes\/:id\/pois',\s*requireWritableRoute\('id'\),\s*upload\.none\(\)/,
|
['put', '/routes/:routeId/pois/:poiId', 'routeId'],
|
||||||
/api\.put\('\/routes\/:routeId\/pois\/:poiId',\s*requireWritableRoute\('routeId'\),\s*upload\.none\(\)/,
|
['post', '/routes/:routeId/pois/:poiId/pictures', 'routeId'],
|
||||||
/api\.post\('\/routes\/:routeId\/pois\/:poiId\/pictures',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/,
|
['put', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'],
|
||||||
/api\.put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/,
|
['post', '/routes/:routeId/pois/:poiId/audio', 'routeId'],
|
||||||
/api\.post\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/,
|
['put', '/routes/:routeId/pois/:poiId/audio', 'routeId'],
|
||||||
/api\.put\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/,
|
['post', '/routes/:id/restore', 'id']
|
||||||
/api\.post\('\/routes\/:id\/restore',\s*requireWritableRoute\('id'\)/
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of writablePatterns) assert.match(api, pattern);
|
for (const [method, endpoint, parameter] of writableRoutes) {
|
||||||
|
const declaration = routeDeclaration(api, method, endpoint);
|
||||||
|
const guard = `requireWritableRoute('${parameter}')`;
|
||||||
|
assert.ok(declaration.includes(guard), `${method.toUpperCase()} ${endpoint} has no release-write guard`);
|
||||||
|
|
||||||
|
const uploadPosition = declaration.indexOf('upload.');
|
||||||
|
if (uploadPosition >= 0) {
|
||||||
|
assert.ok(
|
||||||
|
declaration.indexOf(guard) < uploadPosition,
|
||||||
|
`${method.toUpperCase()} ${endpoint} must check writability before processing uploads`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Route-, POI-, Bild- und Audio-DELETE verwenden die lokale Freigabeausnahme', async () => {
|
test('Route-, Streckenpunkt-, Bild- und Audio-DELETE verwenden die lokale Freigabeausnahme', async () => {
|
||||||
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
||||||
|
const deleteRoutes = [
|
||||||
const deletePatterns = [
|
['delete', '/routes/:id', 'id'],
|
||||||
/api\.delete\('\/routes\/:id',\s*requireRouteDeleteAllowed\('id'\)/,
|
['delete', '/routes/:routeId/pois/:poiId', 'routeId'],
|
||||||
/api\.delete\('\/routes\/:routeId\/pois\/:poiId',\s*requireRouteDeleteAllowed\('routeId'\)/,
|
['delete', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'],
|
||||||
/api\.delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireRouteDeleteAllowed\('routeId'\)/,
|
['delete', '/routes/:routeId/pois/:poiId/audio', 'routeId']
|
||||||
/api\.delete\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireRouteDeleteAllowed\('routeId'\)/
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of deletePatterns) assert.match(api, pattern);
|
for (const [method, endpoint, parameter] of deleteRoutes) {
|
||||||
});
|
const declaration = routeDeclaration(api, method, endpoint);
|
||||||
|
assert.ok(
|
||||||
|
declaration.includes(`requireRouteDeleteAllowed('${parameter}')`),
|
||||||
test('nginx kennzeichnet weitergeleitete Internet-Aufrufe für die Local-DELETE-Prüfung', async () => {
|
`${method.toUpperCase()} ${endpoint} has no local-delete guard`
|
||||||
const nginx = await fs.readFile(path.join(root, 'nginx/wegwichtel.conf'), 'utf8');
|
);
|
||||||
|
}
|
||||||
assert.match(nginx, /proxy_pass\s+http:\/\/127\.0\.0\.1:47145;/);
|
|
||||||
assert.match(nginx, /proxy_set_header\s+X-Real-IP\s+\$remote_addr;/);
|
|
||||||
assert.match(nginx, /proxy_set_header\s+X-Forwarded-For\s+\$proxy_add_x_forwarded_for;/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user