import { loadI18n } from '../test-support/i18n.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; import vm from 'node:vm'; import { webcrypto } from 'node:crypto'; const root = path.resolve(import.meta.dirname, '..'); const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1'; const POI_NAMESPACE = 'urn:wegwichtel:poi'; function escapeXml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function decodeXml(value) { return String(value) .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('&', '&'); } class XmlElement { constructor(namespaceURI, tagName) { this.namespaceURI = namespaceURI; this.tagName = tagName; this.localName = tagName.includes(':') ? tagName.split(':').at(-1) : tagName; this.attributes = new Map(); this.children = []; this.parentNode = null; this._text = ''; } get firstChild() { return this.children[0] || null; } get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } set textContent(value) { this.children = []; this._text = String(value ?? ''); } appendChild(child) { child.parentNode = this; this.children.push(child); return child; } insertBefore(child, reference) { child.parentNode = this; const index = reference ? this.children.indexOf(reference) : -1; if (index < 0) this.children.push(child); else this.children.splice(index, 0, child); return child; } before(...nodes) { if (!this.parentNode) return; const index = this.parentNode.children.indexOf(this); if (index < 0) return; for (const node of nodes) node.parentNode = this.parentNode; this.parentNode.children.splice(index, 0, ...nodes); } remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } getElementsByTagName(name) { const result = []; for (const child of this.children) { if (name === '*' || child.tagName === name || child.localName === name) result.push(child); result.push(...child.getElementsByTagName(name)); } return result; } getElementsByTagNameNS(namespaceURI, localName) { const result = []; for (const child of this.children) { const namespaceMatches = namespaceURI === '*' || child.namespaceURI === namespaceURI; if (namespaceMatches && child.localName === localName) result.push(child); result.push(...child.getElementsByTagNameNS(namespaceURI, localName)); } return result; } } class XmlDocument { constructor(rootElement = null) { this.documentElement = rootElement; } querySelector(selector) { return selector === 'parsererror' ? null : null; } createElementNS(namespaceURI, tagName) { return new XmlElement(namespaceURI, tagName); } getElementsByTagName(name) { if (!this.documentElement) return []; const own = name === '*' || this.documentElement.tagName === name || this.documentElement.localName === name ? [this.documentElement] : []; return [...own, ...this.documentElement.getElementsByTagName(name)]; } getElementsByTagNameNS(namespaceURI, localName) { if (!this.documentElement) return []; const ownNamespaceMatches = namespaceURI === '*' || this.documentElement.namespaceURI === namespaceURI; const own = ownNamespaceMatches && this.documentElement.localName === localName ? [this.documentElement] : []; return [...own, ...this.documentElement.getElementsByTagNameNS(namespaceURI, localName)]; } } class FakeDOMParser { parseFromString(xml) { const tokens = String(xml) .replace(/<\?xml[\s\S]*?\?>/gi, '') .replace(//g, '') .match(/<[^>]+>|[^<]+/g) || []; const stack = []; let rootElement = null; for (const token of tokens) { if (!token.trim()) continue; if (token.startsWith('$/.test(token); const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/); if (!match) return new XmlDocument(new XmlElement('', 'parsererror')); const [, tagName, attributeText] = match; const namespaceURI = tagName.startsWith('wegwichtel:') ? POI_NAMESPACE : GPX_NAMESPACE; const element = new XmlElement(namespaceURI, tagName); const attributePattern = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g; for (const attribute of attributeText.matchAll(attributePattern)) { element.setAttribute(attribute[1], decodeXml(attribute[3])); } if (stack.length) stack.at(-1).appendChild(element); else if (!rootElement) rootElement = element; else return new XmlDocument(new XmlElement('', 'parsererror')); if (!selfClosing) stack.push(element); } if (!rootElement || stack.length) return new XmlDocument(new XmlElement('', 'parsererror')); return new XmlDocument(rootElement); } } function serializeElement(element) { const attributes = [...element.attributes] .map(([name, value]) => ` ${name}="${escapeXml(value)}"`) .join(''); const content = escapeXml(element._text) + element.children.map(serializeElement).join(''); return `<${element.tagName}${attributes}>${content}`; } class FakeXMLSerializer { serializeToString(document) { return serializeElement(document.documentElement); } } class FakeStorage { constructor() { this.values = new Map(); this.failNext = false; } getItem(key) { return this.values.has(key) ? this.values.get(key) : null; } setItem(key, value) { if (this.failNext) { this.failNext = false; const error = new Error('quota'); error.name = 'QuotaExceededError'; throw error; } this.values.set(String(key), String(value)); } removeItem(key) { this.values.delete(String(key)); } } 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); this.detail = options.detail; } } class FakeFileReader extends EventTarget { readAsText(file) { queueMicrotask(() => { this.result = file.content || ''; this.dispatchEvent(new Event('load')); }); } readAsDataURL(file) { queueMicrotask(() => { const type = file.type || 'application/octet-stream'; this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); }); } } async function loadLocalRoutes(options = {}) { const sourcePath = path.join(root, 'public/js/local-routes.js'); const source = await fs.readFile(sourcePath, 'utf8'); const localStorage = options.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, EventTarget, FileReader: FakeFileReader, XMLSerializer: FakeXMLSerializer, crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555', getRandomValues: bytes => webcrypto.getRandomValues(bytes) }, document, globalThis: null, indexedDB, localStorage, queueMicrotask, URL: { createObjectURL: blob => `blob:test-${blob.size}` }, Wegwichtel: {} }; context.globalThis = context; await loadI18n(context); vm.runInNewContext(source, context, { filename: sourcePath }); return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage, context }; } const sampleGpx = ` Alter Name Import77 Weg 100 112 `; test('missing GPS samples and stations survive storage and later manual coordinates', async () => { const { api } = await loadLocalRoutes(); let gpx = api.appendTrackPoint(api.emptyGpx('Ohne Empfang'), { timestamp: 1000 }, 'Ohne Empfang'); gpx = api.appendTrackPoint(gpx, { lat: 50, lon: 10, timestamp: 2000 }, 'Ohne Empfang'); const saved = api.save({ id: 'missing-gps', gpx, route: { name: 'Ohne Empfang' }, pois: [ { id: 42, title: 'Drinnen', lat: null, lon: null, sequence: 0 } ] }); const positions = api.recordedPositions(saved.gpx); assert.equal(positions.length, 2); assert.equal(positions[0].lat, null); assert.equal(api.parseWaypoints(saved.gpx)[0].lat, null); assert.match(api.gpxForUpload(saved), /gpsGap/); positions[0].lat = 49.999; positions[0].lon = 9.999; const edited = api.replaceRecordedPositions(saved.gpx, positions, 'Ohne Empfang'); assert.doesNotMatch(edited, /gpsGap/); assert.equal(api.recordedPositions(edited)[0].lat, 49.999); assert.equal(api.parseWaypoints(edited)[0].title, 'Drinnen'); assert.equal(api.parseWaypoints(edited)[0].lat, null); }); test('route id generation uses randomUUID when available', async () => { const { api } = await loadLocalRoutes(); assert.equal( api.createRouteId(), 'local-11111111-2222-4333-8444-555555555555' ); }); test('route id generation falls back to crypto.getRandomValues with UUID v4 bits', async () => { let callCount = 0; const { api } = await loadLocalRoutes({ crypto: { getRandomValues(bytes) { callCount += 1; bytes.set([ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff ]); return bytes; } } }); assert.equal(api.createRouteId(), 'local-00112233-4455-4677-8899-aabbccddeeff'); assert.equal(callCount, 1); }); test('route id generation fails when no secure crypto source is available', async () => { const { api } = await loadLocalRoutes({ crypto: {} }); assert.throws( () => api.createRouteId(), /keine neue Strecke angelegt/ ); }); test('local route package stores GPX and JSON separately and reconstructs an app route', async () => { const { api, events, localStorage } = await loadLocalRoutes(); const routeId = api.createRouteId(); assert.equal(routeId, 'local-11111111-2222-4333-8444-555555555555'); const saved = api.save({ id: routeId, gpx: api.emptyGpx('Neue Strecke'), route: { id: routeId, name: 'Neue Strecke', description: 'Lokal gespeichert', serverRouteId: 42, uploadedAt: '2026-09-27T18:00:00.000Z' }, pois: [ { id: 101, title: 'Start', description: 'Erster POI', lat: 50.1, lon: 10.1, triggerRadiusM: 30, audio: { dataUrl: 'data:audio/mpeg;base64,QQ==', name: 'start.mp3' }, images: [{ id: 'img-1', dataUrl: 'data:image/jpeg;base64,Qg==', caption: 'Bild' }] }, { id: 102, title: 'Ziel', description: 'Zweiter POI', lat: 50.101, lon: 10.102, triggerRadiusM: 25, images: [] } ] }); assert.equal(saved.route.name, 'Neue Strecke'); assert.equal(saved.pois.length, 2); assert.equal(events.at(-1).action, 'saved'); assert.equal(events.at(-1).id, routeId); const prefix = api.STORAGE_PREFIX; const encodedId = encodeURIComponent(routeId); const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`); const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); assert.match(gpx, //); assert.match(gpx, /101<\/wegwichtel:poiId>/); assert.doesNotMatch(gpx, / { const { api } = await loadLocalRoutes(); const imported = api.parseWaypoints(sampleGpx); assert.equal(imported.length, 1); assert.equal(imported[0].id, 77); assert.equal(imported[0].title, 'Import'); assert.equal(imported[0].lat, 50.1); assert.equal(imported[0].lon, 10.1); assert.match(api.emptyGpx('A & B'), /A & B<\/name>/); assert.throws(() => api.parseWaypoints(''), /keinen lesbaren Weg/); assert.match(api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /pendingWaypoint/); const waypointOnly = api.syncGpx( ``, { name: 'Nur Station' }, [{ id: 2, title: 'Einzelner Station', lat: 50.2, lon: 10.2, sequence: 0 }] ); 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 Station', 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 keinen aufgezeichneten Weg/); }); test('remove updates index and dispatches a local-route change event', async () => { const { api, events } = await loadLocalRoutes(); api.save({ id: 'local-remove', gpx: sampleGpx, route: { name: 'Entfernen' }, pois: [] }); assert.equal(api.list().length, 1); 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'); await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/); }); test('quota failure rolls back package writes and keeps the previous package readable', async () => { const { api, localStorage } = await loadLocalRoutes(); api.save({ id: 'local-quota', gpx: sampleGpx, route: { name: 'Vorher' }, pois: [] }); localStorage.failNext = true; assert.throws(() => api.save({ id: 'local-quota', gpx: sampleGpx, route: { name: 'Nachher' }, pois: [] }), /Platz für deine Strecken 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: 'Station 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(); assert.equal(await api.readFileAsText({ text: async () => '' }), ''); assert.equal(await api.readFileAsText(null), ''); await assert.rejects(api.readFileAsText({}), { name: 'TypeError' }); const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' }); assert.equal(media.name, 'ton.mp3'); assert.equal(media.size, 4); assert.equal(media.type, 'audio/mpeg'); assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/); await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' }); 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; } }; 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); api.save({ id: 'route-handle', gpx: api.emptyGpx('Handle'), route: { name: 'Handle' }, pois: [{ id: 7, title: 'Station', 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/); assert.equal(await api.mediaBlob(media), file); permission = 'prompt'; await assert.rejects(api.mediaBlob(media), /Erlaube noch einmal/); assert.equal(await api.mediaBlob(media, { requestPermission: true }), file); assert.equal(permissionRequests, 1); }); test('multiple Stationen 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 Stationen'), route: { name: 'Mehrere Stationen' }, pois: [ { id: 1, title: 'Erster Station', lat: 50.1, lon: 10.1, images: [first] }, { id: 2, title: 'Zweiter Station', lat: 50.2, lon: 10.2, images: [second] } ] }); 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); }); test('256-bit creator identity survives reload, route deletion and requires explicit confirmation to reset', async () => { const first = await loadLocalRoutes(); const identity = first.api.creatorId(); assert.match(identity, /^[a-f0-9]{64}$/); const second = await loadLocalRoutes({ localStorage: first.localStorage }); assert.equal(second.api.creatorId(), identity); const saved = second.api.save({ id: 'identity-test', route: { name: 'Test' }, pois: [] }); assert.equal(saved.route.creator_id, identity); await second.api.remove('identity-test'); assert.equal(second.api.creatorId(), identity); second.context.confirm = () => false; assert.equal(second.api.forgetCreatorId(), false); assert.equal(second.api.creatorId(), identity); second.context.confirm = () => true; assert.equal(second.api.forgetCreatorId(), true); assert.notEqual(second.api.creatorId(), identity); const other = await loadLocalRoutes(); assert.notEqual(other.api.creatorId(), identity); }); test('creator identity never falls back to insecure randomness or replaces corrupt stored identity', async () => { const { api, localStorage } = await loadLocalRoutes({ crypto: {} }); assert.throws(() => api.creatorId(), error => error.name === 'TypeError' && /Gerätekennung/.test(error.message)); localStorage.setItem('wegwichtel.creator-id.v1', 'broken'); assert.throws(() => api.creatorId(), /Gerätekennung/); assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken'); }); test('route selection reads metadata only and station media is loaded and released separately', async () => { const { api, context } = await loadLocalRoutes(); const reads = [0, 0]; const revoked = []; let urlNumber = 0; context.URL.createObjectURL = () => `blob:lazy-${++urlNumber}`; context.URL.revokeObjectURL = url => revoked.push(url); const files = []; for (let id = 0; id < 2; id++) { const file = Object.assign(new Blob(['image']), { name: `${id}.jpg` }); const handle = { async getFile() { reads[id]++; return file; } }; files.push(await api.storeMediaFile('lazy', id, 'image', id, file, handle)); } api.save({ id: 'lazy', route: { name: 'Lazy' }, pois: files.map((image, id) => ({ id, images: [image] })) }); await api.getAppRoute('lazy'); api.get('lazy'); assert.deepEqual(reads, [0, 0]); const first = await api.getPoiWithMedia('lazy', 0, { requestPermission: true }); assert.deepEqual(reads, [1, 0]); assert.equal(first.images[0].url, 'blob:lazy-1'); const second = await api.getAppPoi('lazy', 1); assert.deepEqual(reads, [1, 1]); assert.equal(second.images[0].url, 'blob:lazy-2'); api.releasePoiMedia(first); api.releasePoiMedia(first); api.releasePoiMedia(second); api.releasePoiMedia({ images: [{ url: 'https://example.org/image.jpg' }] }); assert.deepEqual(revoked, ['blob:lazy-1', 'blob:lazy-2']); assert.equal(await api.getPoiWithMedia('lazy', 9), null); assert.equal(await api.getAppPoi('missing', 0), null); assert.equal(api.get('lazy').pois[0].images[0].url, undefined); });