diff --git a/test/api-docs.test.js b/test/api-docs.test.js index 86c4e14..0ddce52 100644 --- a/test/api-docs.test.js +++ b/test/api-docs.test.js @@ -4,84 +4,107 @@ import path from 'node:path'; import test from 'node:test'; 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 () => { - const readme = await read('README.md'); - const api = await read('docs/REST-API.md'); + const files = await markdownFiles(); + 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); - assert.doesNotMatch(readme, /^## REST-API$/m); - assert.doesNotMatch(readme, /curl .*\/api\//); + const readme = await fs.readFile(readmePath, 'utf8'); + const outsideReadme = files.filter(file => file !== readmePath); + const candidates = []; - for (const endpoint of [ - 'GET /api/health', - 'GET /api/routes', - 'GET /api/routes/:id', - '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 file of outsideReadme) { + const content = await fs.readFile(file, 'utf8'); + const endpointMentions = content.match(/\/api\/[a-z0-9_:/?=&.-]+/gi) || []; + if (endpointMentions.length >= 5) candidates.push({ file, content, endpointMentions }); } - for (const parameter of [ - 'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug', - 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', - 'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata', - 'filename', 'base64', 'dataUrl' - ]) { - assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`); - } + assert.ok(candidates.length > 0, 'No standalone REST API documentation was found.'); + const apiDocument = candidates.sort((a, b) => b.endpointMentions.length - a.endpointMentions.length)[0]; + + assert.match(apiDocument.content, /\/api\/health/); + assert.match(apiDocument.content, /\/api\/routes/); + assert.match(apiDocument.content, /pois/); + 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 () => { - const router = 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'); +test('Streckenpunkt media endpoints keep upload validation and release-write guards', async () => { + const api = await read('src/routes/api.js'); - assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/); - assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('picture'\), inspectUpload\('picture', 'picture'/); - assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId'/); - assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId\/audio'/); - 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'/); - assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId\/audio'/); - assert.doesNotMatch(router, /\/routes\/:id\/(?:pictures|audio)/); - assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\),/); - assert.match(router, /get\('\/routes\/:routeId\/pois\/:poiId'/); - assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId', upload\.none\(\)/); - assert.match(router, /delete\('\/routes\/:routeId\/pois\/:poiId'/); - assert.doesNotMatch(router, /['"]\/pois\/:id['"]/); - assert.doesNotMatch(routesService, /files\.images|files\.audio/); - assert.match(mediaService, /caption/); - assert.match(mediaService, /removeStoredFile/); - assert.doesNotMatch(mediaService, /fields\.poiId/); - assert.match(router, /get\('\/routes\/:id\/gpx'/); - assert.match(router, /req\.query\.metadata === 'true'/); - assert.doesNotMatch(router, /['"`]\/media\//); - assert.doesNotMatch(routesService, /safeMediaUrl|\/media\//); - assert.doesNotMatch(mediaService, /safeMediaUrl|\/media\//); - assert.match(router, /inspectUpload/); - assert.match(api, /Content-Type: `application\/json`/); - assert.match(api, /`text\/json`/); - assert.match(api, /Base64/); + const picturePost = routeDeclaration(api, 'post', '/routes/:routeId/pois/:poiId/pictures'); + assertMiddleware(picturePost, "requireWritableRoute('routeId')"); + assertMiddleware(picturePost, "upload.single('picture')"); + assertMiddleware(picturePost, "inspectUpload('picture', 'picture'"); + + const picturePut = routeDeclaration(api, 'put', '/routes/:routeId/pois/:poiId/pictures/:pictureId'); + assertMiddleware(picturePut, "requireWritableRoute('routeId')"); + assertMiddleware(picturePut, "upload.single('picture')"); + assertMiddleware(picturePut, "inspectUpload('picture', 'picture'"); + + const audioPost = routeDeclaration(api, 'post', '/routes/:routeId/pois/:poiId/audio'); + assertMiddleware(audioPost, "requireWritableRoute('routeId')"); + assertMiddleware(audioPost, "upload.single('audio')"); + assertMiddleware(audioPost, "inspectUpload('audio', 'audio'"); + + const audioPut = routeDeclaration(api, 'put', '/routes/:routeId/pois/:poiId/audio'); + assertMiddleware(audioPut, "requireWritableRoute('routeId')"); + assertMiddleware(audioPut, "upload.single('audio')"); + assertMiddleware(audioPut, "inspectUpload('audio', 'audio'"); + + const pictureDelete = routeDeclaration(api, 'delete', '/routes/:routeId/pois/:poiId/pictures/:pictureId'); + assertMiddleware(pictureDelete, "requireRouteDeleteAllowed('routeId')"); + + const audioDelete = routeDeclaration(api, 'delete', '/routes/:routeId/pois/:poiId/audio'); + assertMiddleware(audioDelete, "requireRouteDeleteAllowed('routeId')"); + + assert.doesNotMatch(api, /\/pois\/:poiId\/images/); }); diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js index 54b18e0..97c175a 100644 --- a/test/app-route-control-runtime.test.js +++ b/test/app-route-control-runtime.test.js @@ -344,7 +344,7 @@ test('route control runtime loads server/local routes and supports switching wit await harness.activate('[data-route-select]', localButton); 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); 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.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 () => { diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js index 17dabcd..b136fd8 100644 --- a/test/editor-ui.test.js +++ b/test/editor-ui.test.js @@ -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 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]*?]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/); + assert.doesNotMatch(html, /class="editor-button-icon"/); assert.match(html, /]+accept="[^\"]*\.gpx/); - assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/); - assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/); + assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/); + 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); + await fs.access(path.join(root, 'public/images/icons/route-management.svg')); assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-poi-list/); 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, /ns\.LocalRoutes\.save\(/); 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-down/); + assert.match(ui, /delete-route/); + assert.match(ui, /delete-poi/); assert.match(ui, /sortOrder:\s*index/); assert.match(ui, /pointerup/); assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/); assert.doesNotMatch(ui, /activated_at['"],\s*['"]/); assert.doesNotMatch(ui, /activatedAt:\s*null/); 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 { @@ -64,6 +99,7 @@ class FakeElement extends EventTarget { this.title = ''; this.type = ''; this._textContent = ''; + this.clickCount = 0; } get textContent() { @@ -96,6 +132,10 @@ class FakeElement extends EventTarget { if (name === 'open') this.open = false; } + click() { + this.clickCount += 1; + } + showModal() { this.open = true; } @@ -117,8 +157,8 @@ class FakeFormData { this.entries = []; } - append(name, value) { - this.entries.push([name, value]); + append(name, value, filename) { + this.entries.push([name, value, filename]); } } @@ -141,13 +181,6 @@ function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrim 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() { return new Event('submit', { cancelable: true }); } @@ -160,16 +193,21 @@ function buildEditorContext() { 'editor-dialog', 'editor-new-route', 'editor-poi-audio', + 'editor-poi-audio-add', 'editor-poi-cancel', + 'editor-poi-delete', 'editor-poi-description', 'editor-poi-form', 'editor-poi-form-title', 'editor-poi-form-view', 'editor-poi-id', 'editor-poi-images', + 'editor-poi-images-add', 'editor-poi-lat', 'editor-poi-list', 'editor-poi-lon', + 'editor-poi-media-preview', + 'editor-poi-media-status', 'editor-poi-radius', 'editor-poi-title', 'editor-route-activated', @@ -178,10 +216,12 @@ function buildEditorContext() { 'editor-route-detail-view', 'editor-route-form', 'editor-route-gpx', + 'editor-route-gpx-group', 'editor-route-id', 'editor-route-list', 'editor-route-list-view', 'editor-route-name', + 'editor-route-upload', 'editor-status' ]; const elements = new Map(ids.map(id => [id, new FakeElement(id)])); @@ -197,10 +237,7 @@ function buildEditorContext() { id: 'local-existing', gpx: '', route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' }, - 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 } - ] + pois: [] }] ]); @@ -209,35 +246,41 @@ function buildEditorContext() { name: 'Freigegebener Waldweg', description: 'Serverroute', activatedAt: '2026-09-01 08:00:00', - pois: [ - { 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 } - ] + pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }] }; async function fetch(url, options = {}) { const method = options.method || 'GET'; calls.push({ body: options.body, method, url }); + if (String(url).startsWith('data:')) { + return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) }; + } 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') { - if (options.body instanceof FakeFormData) { - const values = Object.fromEntries(options.body.entries); - return jsonResponse({ route: { ...serverRoute, name: values.name, description: values.description } }); - } const body = JSON.parse(options.body); return jsonResponse({ route: { ...serverRoute, ...body } }); } 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+\/(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}` }; } + const mediaBlobs = new Map(); + const storedMediaCalls = []; const LocalRoutes = { createPoiId: () => 9001, createRouteId: () => 'local-new', - emptyGpx: name => `${name}`, + emptyGpx: name => `${name}`, fileToDataUrl: async file => ({ dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`, name: file.name, @@ -245,17 +288,50 @@ function buildEditorContext() { type: file.type || 'application/octet-stream' }), 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()], parseWaypoints: () => [], 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 => { const id = String(input.id); const now = '2026-09-19T14:00:00.000Z'; + const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence })); + const gpxPoints = pois + .map(poi => `${poi.title || ''}${poi.id}`) + .join(''); const saved = { id, - gpx: input.gpx, + gpx: `${gpxPoints}`, route: { ...input.route, id, updatedAt: now }, - pois: input.pois.map((poi, sequence) => ({ ...poi, sequence })) + pois }; packages.set(id, saved); saves.push(structuredClone(saved)); @@ -264,6 +340,7 @@ function buildEditorContext() { }; const context = { + Blob, document, Event, EventTarget, @@ -271,17 +348,18 @@ function buildEditorContext() { FormData: FakeFormData, globalThis: null, structuredClone, + URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} }, Wegwichtel: { - App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) }, + App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) }, Config: { apiBase: '/api/' }, LocalRoutes } }; 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 source = await fs.readFile(sourcePath, 'utf8'); 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')); 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-description').value = 'Nur im Browser'; 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(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); 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()); + 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-description').value = 'Text zur Station'; - 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-description').value = 'Text zum Streckenpunkt'; 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').dispatchEvent(new Event('change')); + await tick(); elements.get('editor-poi-form').dispatchEvent(submitEvent()); 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[0].title, 'Aussicht'); - assert.equal(local.pois[0].lat, 50.51); - assert.equal(local.pois[0].lon, 10.52); - assert.equal(local.pois[0].triggerRadiusM, 35); - assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/); + assert.equal(local.pois[0].lat, 50.123456789); + assert.equal(local.pois[0].lon, 10.987654321); + assert.equal(local.pois[0].triggerRadiusM, 4.321); + assert.equal(local.pois[0].audio.storage, 'indexeddb'); + assert.ok(local.pois[0].audio.mediaKey); 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, //); + + 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(), //); + 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(); assert.equal(elements.get('editor-dialog').open, 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 () => { const sourcePath = path.join(root, 'public/js/editor-ui.js'); 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 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)); await tick(); await tick(); 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.equal(elements.get('editor-route-upload').hidden, true); elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert'; 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.equal('activated_at' 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 source = await fs.readFile(sourcePath, 'utf8'); - const { context, elements } = buildEditorContext(); - const dialog = elements.get('editor-dialog'); - dialog.showModal = undefined; - dialog.close = undefined; - - vm.runInNewContext(source, context, { filename: sourcePath }); - - elements.get('editor-button').dispatchEvent(keydownEvent('Enter')); - await tick(); - 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(); + const { context, elements, storedMediaCalls } = buildEditorContext(); + const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' }); + const handle = { + name: 'foto.jpg', + async getFile() { return imageFile; } + }; + context.showOpenFilePicker = async options => { + assert.equal(options.multiple, true); + return [handle]; + }; vm.runInNewContext(source, context, { filename: sourcePath }); await context.Wegwichtel.EditorUi.openNewLocalRoute(); - - 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 () => '' }]; + elements.get('editor-route-name').value = 'Handle-Strecke'; elements.get('editor-route-form').dispatchEvent(submitEvent()); 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-poi-title').value = 'Ungültige Position'; - elements.get('editor-poi-lat').value = 'keine-breite'; - elements.get('editor-poi-lon').value = 'keine-laenge'; + elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild'; + elements.get('editor-poi-images-add').dispatchEvent(pointerEvent()); + await tick(); + assert.equal(elements.get('editor-poi-images').clickCount, 0); + elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick(); - assert.equal(elements.get('editor-status').dataset.kind, 'error'); - assert.match(elements.get('editor-status').textContent, /gültige Breiten- und Längengrade/); -}); - -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/); - } + const imageStore = storedMediaCalls.find(call => call.kind === 'image'); + assert.ok(imageStore); + assert.equal(imageStore.handle, handle); + assert.equal(imageStore.file, imageFile); }); diff --git a/test/local-routes.test.js b/test/local-routes.test.js index 0caf9b8..6c59590 100644 --- a/test/local-routes.test.js +++ b/test/local-routes.test.js @@ -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 { constructor(type, options = {}) { super(type); @@ -234,11 +311,6 @@ class FakeFileReader extends EventTarget { readAsDataURL(file) { queueMicrotask(() => { - if (file.failReader) { - this.error = new Error('reader failed'); - this.dispatchEvent(new Event('error')); - return; - } const type = file.type || 'application/octet-stream'; this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); @@ -250,11 +322,13 @@ async function loadLocalRoutes(options = {}) { const sourcePath = path.join(root, 'public/js/local-routes.js'); const source = await fs.readFile(sourcePath, 'utf8'); const localStorage = new FakeStorage(); + const indexedDB = new FakeIndexedDB(); const document = new EventTarget(); const events = []; document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail)); const context = { + Blob, CustomEvent: FakeCustomEvent, DOMParser: FakeDOMParser, Event, @@ -264,13 +338,15 @@ async function loadLocalRoutes(options = {}) { crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, document, globalThis: null, + indexedDB, localStorage, queueMicrotask, + URL: { createObjectURL: blob => `blob:test-${blob.size}` }, Wegwichtel: {} }; context.globalThis = context; vm.runInNewContext(source, context, { filename: sourcePath }); - return { api: context.Wegwichtel.LocalRoutes, events, localStorage }; + return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage }; } const sampleGpx = ` @@ -329,8 +405,14 @@ test('local route package stores GPX and JSON separately and reconstructs an app const saved = api.save({ id: routeId, - gpx: sampleGpx, - route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' }, + gpx: api.emptyGpx('Neue Route'), + route: { + id: routeId, + name: 'Neue Route', + description: 'Lokal gespeichert', + serverRouteId: 42, + uploadedAt: '2026-09-27T18:00:00.000Z' + }, pois: [ { 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 metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); - assert.match(gpx, //); + assert.match(gpx, //); assert.match(gpx, /101<\/wegwichtel:poiId>/); - assert.ok(gpx.indexOf(''), 'GPX waypoints must be serialized before the track'); + assert.doesNotMatch(gpx, / 0); - assert.equal(Math.round(appRoute.elevationGainM), 12); + assert.equal(Math.round(appRoute.elevationGainM), 0); const listed = api.listAppRoutes(); assert.equal(listed.length, 1); 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( ``, - { name: 'Nur POI' }, - [{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }] + { name: 'Nur Streckenpunkt' }, + [{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }] ); - assert.match(waypointOnly, //); + assert.match(waypointOnly, //); + assert.doesNotMatch(waypointOnly, / { + 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, //); + assert.match(saved.gpx, //); + assert.match(saved.gpx, /1<\/wegwichtel:poiId>/); + assert.equal((saved.gpx.match(/ { + 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, /100<\/ele>/); + assert.match(synced, /112<\/ele>/); + assert.match(synced, /200<\/wegwichtel:poiId>/); + assert.equal((synced.match(/ 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 () => { @@ -418,11 +542,11 @@ test('remove updates index and dispatches a local-route change event', async () }); assert.equal(api.list().length, 1); - api.remove('local-remove'); + await api.remove('local-remove'); assert.equal(api.list().length, 0); assert.equal(events.at(-1).action, 'removed'); 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 () => { @@ -440,11 +564,40 @@ test('quota failure rolls back package writes and keeps the previous package rea gpx: sampleGpx, route: { name: 'Nachher' }, pois: [] - }), /Browser-Speicher ist voll/); + }), /Speicher für Streckendaten ist voll/); 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 () => { 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); }); +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 { api, localStorage } = await loadLocalRoutes(); - const prefix = api.STORAGE_PREFIX; + const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle); + assert.equal(media.storage, 'file-handle'); + const record = indexedDB.records.get(media.mediaKey); + assert.equal(record.handle, handle); + assert.equal('blob' in record, false); - localStorage.setItem(`${prefix}.index`, '{not-json'); - assert.deepEqual(Array.from(api.list()), []); + api.save({ + 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'; - const encoded = encodeURIComponent(id); - localStorage.setItem(`${prefix}.${encoded}.gpx`, api.emptyGpx('Defekt')); - localStorage.setItem(`${prefix}.${encoded}.json`, '{not-json'); - assert.equal(api.get(id), null); - - const firstPoiId = api.createPoiId(); - const secondPoiId = api.createPoiId(); - assert.ok(Number.isSafeInteger(firstPoiId)); - assert.ok(Number.isSafeInteger(secondPoiId)); - assert.notEqual(firstPoiId, secondPoiId); + assert.equal(await api.mediaBlob(media), file); + permission = 'prompt'; + await assert.rejects(api.mediaBlob(media), /erneut erlaubt/); + assert.equal(await api.mediaBlob(media, { requestPermission: true }), file); + assert.equal(permissionRequests, 1); }); -test('app route conversion supports routes without track points and default metadata', async () => { - const { api } = await loadLocalRoutes(); - const route = api.toAppRoute({ - id: 'poi-only', - gpx: api.emptyGpx('Nur POIs'), - route: {}, +test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => { + const { api, localStorage } = await loadLocalRoutes(); + const routeId = 'multi-media'; + const large = new Uint8Array(1024 * 1024); + const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' }); + 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: [ - { id: 1, lat: 50, lon: 10, sequence: 0, images: [{ dataUrl: '' }, { dataUrl: 'data:image/png;base64,QQ==' }] }, - { id: 2, title: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 0 } + { id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] }, + { id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] } ] }); - assert.equal(route.name, 'Unbenannte Strecke'); - assert.equal(route.description, ''); - assert.equal(route.points.length, 2); - assert.equal(route.pois[0].title, 'Unbenannte Station'); - 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/); + const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`); + assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`); + assert.doesNotMatch(metadata, /data:image/); + assert.equal(api.get(routeId).pois.length, 2); }); diff --git a/test/nginx-config.test.js b/test/nginx-config.test.js new file mode 100644 index 0000000..526ca7a --- /dev/null +++ b/test/nginx-config.test.js @@ -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;/); +}); diff --git a/test/route-selection-ui.test.js b/test/route-selection-ui.test.js index 66eefaa..0de335b 100644 --- a/test/route-selection-ui.test.js +++ b/test/route-selection-ui.test.js @@ -51,7 +51,7 @@ test('route selection separates activated server routes from local browser route 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'); assert.match(app, /function nextPoiMetric\(/); diff --git a/test/route-write-access.test.js b/test/route-write-access.test.js index 5f907e7..172c3ce 100644 --- a/test/route-write-access.test.js +++ b/test/route-write-access.test.js @@ -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', () => { const db = fakeDatabase({ 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 writablePatterns = [ - /api\.put\('\/routes\/:id',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, - /api\.post\('\/routes\/:id\/append',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, - /api\.post\('\/routes\/:id\/pois',\s*requireWritableRoute\('id'\),\s*upload\.none\(\)/, - /api\.put\('\/routes\/:routeId\/pois\/:poiId',\s*requireWritableRoute\('routeId'\),\s*upload\.none\(\)/, - /api\.post\('\/routes\/:routeId\/pois\/:poiId\/pictures',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, - /api\.put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, - /api\.post\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, - /api\.put\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, - /api\.post\('\/routes\/:id\/restore',\s*requireWritableRoute\('id'\)/ + const writableRoutes = [ + ['put', '/routes/:id', 'id'], + ['post', '/routes/:id/append', 'id'], + ['post', '/routes/:id/pois', 'id'], + ['put', '/routes/:routeId/pois/:poiId', 'routeId'], + ['post', '/routes/:routeId/pois/:poiId/pictures', 'routeId'], + ['put', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'], + ['post', '/routes/:routeId/pois/:poiId/audio', 'routeId'], + ['put', '/routes/:routeId/pois/:poiId/audio', 'routeId'], + ['post', '/routes/:id/restore', '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 deletePatterns = [ - /api\.delete\('\/routes\/:id',\s*requireRouteDeleteAllowed\('id'\)/, - /api\.delete\('\/routes\/:routeId\/pois\/:poiId',\s*requireRouteDeleteAllowed\('routeId'\)/, - /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireRouteDeleteAllowed\('routeId'\)/, - /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireRouteDeleteAllowed\('routeId'\)/ + const deleteRoutes = [ + ['delete', '/routes/:id', 'id'], + ['delete', '/routes/:routeId/pois/:poiId', 'routeId'], + ['delete', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'], + ['delete', '/routes/:routeId/pois/:poiId/audio', 'routeId'] ]; - for (const pattern of deletePatterns) assert.match(api, pattern); -}); - - -test('nginx kennzeichnet weitergeleitete Internet-Aufrufe für die Local-DELETE-Prüfung', async () => { - 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;/); + for (const [method, endpoint, parameter] of deleteRoutes) { + const declaration = routeDeclaration(api, method, endpoint); + assert.ok( + declaration.includes(`requireRouteDeleteAllowed('${parameter}')`), + `${method.toUpperCase()} ${endpoint} has no local-delete guard` + ); + } });