import assert from 'node:assert/strict'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; const runtime = await fsp.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-services-')); process.env.DATA_DIR = path.join(runtime, 'data'); process.env.STORAGE_DIR = path.join(runtime, 'storage'); const { config, ensureRuntimeDirectories } = await import('../src/config.js'); const { openDatabase, transaction } = await import('../src/database.js'); const { HttpError, notFoundHandler, errorHandler } = await import('../src/middleware/errors.js'); const express = (await import('express')).default; const { createApiRouter } = await import('../src/routes/api.js'); const { inspectUpload } = await import('../src/middleware/upload.js'); const routes = await import('../src/services/routes-service.js'); const media = await import('../src/services/media-service.js'); const storage = await import('../src/services/storage.js'); ensureRuntimeDirectories(); const PNG_FILE = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=', 'base64' ); const MP3_FILE = Buffer.concat([ Buffer.from('49443304000000000000', 'hex'), Buffer.from('fffb9064', 'hex'), Buffer.alloc(512) ]); const GPX = ` Teststrecke 100 110 `; const GPX_APPEND = ` 105 `; let uploadCounter = 0; function uploadedFile(name, contents, extra = {}) { const filePath = path.join(config.storageDir, 'uploads', `${Date.now()}-${uploadCounter += 1}-${name}`); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, contents); return { fieldname: extra.fieldname || 'file', originalname: name, encoding: '7bit', mimetype: extra.mimetype || 'application/octet-stream', destination: path.dirname(filePath), filename: path.basename(filePath), path: filePath, size: Buffer.byteLength(contents), ...extra }; } function expectHttpError(callback, status) { assert.throws(callback, error => error instanceof HttpError && error.status === status); } async function invokeUpload(middleware, req) { return new Promise(resolve => { middleware(req, {}, error => resolve(error)); }); } test.after(async () => { await fsp.rm(runtime, { recursive: true, force: true }); }); test('route, POI, media and storage services cover successful and rejected lifecycle paths', async () => { const db = openDatabase(path.join(config.dataDir, 'services.sqlite')); try { assert.equal(transaction(db, () => 42), 42); assert.throws(() => transaction(db, () => { throw new Error('rollback'); }), /rollback/); expectHttpError(() => routes.createRoute(db, {}, null), 400); const invalidGpx = uploadedFile('invalid.gpx', ''); expectHttpError(() => routes.createRoute(db, { name: 'Ungültig' }, invalidGpx), 400); assert.equal(fs.existsSync(invalidGpx.path), false); const route = routes.createRoute(db, { name: 'Äußerst schöne Strecke', slug: ' gleiche route ', description: 'Beschreibung', schoolName: 'Schule' }, uploadedFile('route.gpx', GPX)); assert.equal(route.name, 'Äußerst schöne Strecke'); assert.equal(route.pointCount, 2); assert.ok(fs.existsSync(routes.getRouteGpxFile(db, route.id).absolutePath)); const duplicate = routes.createRoute(db, { name: 'Zweite Strecke', slug: ' gleiche route ' }, uploadedFile('route-2.gpx', GPX)); assert.notEqual(duplicate.slug, route.slug); assert.deepEqual(routes.listRoutes(db, {}).map(item => item.id), [route.id, duplicate.id]); assert.equal(routes.listRoutes(db, { lat: 90, lon: 90, radiusKm: 0.001 }).length, 0); assert.ok(routes.listRoutes(db, { lat: route.start.lat, lon: route.start.lon, radiusKm: 1 })[0].proximityM <= 1); expectHttpError(() => routes.getRoute(db, 999999), 404); expectHttpError(() => routes.getRouteGpxFile(db, 999999), 404); expectHttpError(() => routes.updateRoute(db, 999999, {}, null), 404); const renamed = routes.updateRoute(db, route.id, { name: 'Umbenannt', description: 'Neu', schoolName: 'Neue Schule' }, null); assert.equal(renamed.name, 'Umbenannt'); const replaced = routes.updateRoute(db, route.id, {}, uploadedFile('replacement.gpx', GPX_APPEND)); assert.equal(replaced.pointCount, 1); expectHttpError(() => routes.appendRoute(db, route.id, null), 400); expectHttpError(() => routes.appendRoute(db, 999999, uploadedFile('missing.gpx', GPX)), 404); const appended = routes.appendRoute(db, route.id, uploadedFile('append.gpx', GPX)); assert.equal(appended.pointCount, 3); expectHttpError(() => routes.createPoi(db, 999999, { lat: 1, lon: 2 }), 404); expectHttpError(() => routes.createPoi(db, route.id, { lat: 'x', lon: 2 }), 400); const poi = routes.createPoi(db, route.id, { title: '', description: 'POI', lat: route.start.lat, lon: route.start.lon, triggerRadiusM: '55', sequence: '1' }); assert.equal(poi.title, 'Unbenannter POI'); const secondPoi = routes.createPoi(db, route.id, { title: 'Zweiter POI', lat: route.start.lat, lon: route.start.lon, sequence: '2' }); expectHttpError(() => routes.getPoi(db, route.id, 999999), 404); expectHttpError(() => routes.updatePoi(db, route.id, 999999, {}), 404); expectHttpError(() => routes.updatePoi(db, route.id, poi.id, { sequence: -1 }), 400); const updatedPoi = routes.updatePoi(db, route.id, poi.id, { title: 'Aktualisiert', lat: route.start.lat + 0.001, lon: route.start.lon + 0.001, triggerRadiusM: 60, sequence: 0 }); assert.equal(updatedPoi.title, 'Aktualisiert'); assert.equal(routes.listPois(db, route.id)[0].id, poi.id); expectHttpError(() => media.listPictures(db, route.id, 999999), 404); expectHttpError(() => media.createPicture(db, route.id, poi.id, {}, null), 400); const invalidPicture = uploadedFile('invalid-picture.bin', 'not image', { detectedMime: 'text/plain', detectedExtension: 'txt' }); expectHttpError(() => media.createPicture(db, route.id, poi.id, {}, invalidPicture), 415); assert.equal(fs.existsSync(invalidPicture.path), false); const picture = media.createPicture(db, route.id, poi.id, { caption: 'Bild' }, uploadedFile('picture.png', PNG_FILE, { detectedMime: 'image/png', detectedExtension: 'png' })); assert.equal(picture.sequence, 0); const secondPicture = media.createPicture(db, route.id, poi.id, { caption: 'Bild 2', sequence: 2 }, uploadedFile('picture-2.png', PNG_FILE, { detectedMime: 'image/png', detectedExtension: 'png' })); assert.equal(media.listPictures(db, route.id, poi.id).length, 2); assert.ok(fs.existsSync(media.getPictureFile(db, route.id, poi.id, picture.id).absolutePath)); expectHttpError(() => media.getPicture(db, route.id, poi.id, 999999), 404); expectHttpError(() => media.updatePicture(db, route.id, poi.id, picture.id, { sequence: -1 }, null), 400); const metadataUpdate = media.updatePicture(db, route.id, poi.id, picture.id, { caption: 'Neue Bildbeschreibung', sequence: 1 }, null); assert.equal(metadataUpdate.caption, 'Neue Bildbeschreibung'); const replacement = uploadedFile('replacement.webp', PNG_FILE, { detectedMime: 'image/png', detectedExtension: 'png' }); const replacedPicture = media.updatePicture(db, route.id, poi.id, picture.id, {}, replacement); assert.equal(replacedPicture.id, picture.id); expectHttpError(() => media.getAudio(db, route.id, poi.id), 404); expectHttpError(() => media.createAudio(db, route.id, poi.id, null), 400); const invalidAudio = uploadedFile('invalid-audio.bin', 'bad', { detectedMime: 'text/plain', detectedExtension: 'txt' }); expectHttpError(() => media.createAudio(db, route.id, poi.id, invalidAudio), 415); assert.equal(fs.existsSync(invalidAudio.path), false); const audio = media.createAudio(db, route.id, poi.id, uploadedFile('audio.mp3', MP3_FILE, { detectedMime: 'audio/mpeg', detectedExtension: 'mp3' })); assert.equal(audio.poiId, poi.id); assert.ok(fs.existsSync(media.getAudioFile(db, route.id, poi.id).absolutePath)); expectHttpError(() => media.createAudio(db, route.id, poi.id, uploadedFile('duplicate.mp3', MP3_FILE, { detectedMime: 'audio/mpeg', detectedExtension: 'mp3' })), 409); expectHttpError(() => media.updateAudio(db, route.id, secondPoi.id, uploadedFile('missing.mp3', MP3_FILE, { detectedMime: 'audio/mpeg', detectedExtension: 'mp3' })), 404); const replacedAudio = media.updateAudio(db, route.id, poi.id, uploadedFile('replacement.mp3', MP3_FILE, { detectedMime: 'audio/mpeg', detectedExtension: 'mp3' })); assert.equal(replacedAudio.poiId, poi.id); assert.equal(media.deleteAudio(db, route.id, poi.id).deleted, true); expectHttpError(() => media.deleteAudio(db, route.id, poi.id), 404); assert.equal(media.deletePicture(db, route.id, poi.id, secondPicture.id).deleted, true); expectHttpError(() => media.deletePicture(db, route.id, poi.id, secondPicture.id), 404); const poiPicture = media.createPicture(db, route.id, secondPoi.id, {}, uploadedFile('poi-picture.png', PNG_FILE, { detectedMime: 'image/png', detectedExtension: 'png' })); media.createAudio(db, route.id, secondPoi.id, uploadedFile('poi-audio.mp3', MP3_FILE, { detectedMime: 'audio/mpeg', detectedExtension: 'mp3' })); assert.equal(routes.deletePoi(db, route.id, secondPoi.id).deleted, true); expectHttpError(() => routes.deletePoi(db, route.id, secondPoi.id), 404); expectHttpError(() => media.getPicture(db, route.id, secondPoi.id, poiPicture.id), 404); const deleted = routes.softDeleteRoute(db, route.id); assert.equal(deleted.status, 'deleted'); assert.equal(routes.listRoutes(db, {}).some(item => item.id === route.id), false); assert.equal(routes.listRoutes(db, { includeDeleted: true }).some(item => item.id === route.id), true); expectHttpError(() => routes.getRoute(db, route.id), 404); expectHttpError(() => routes.softDeleteRoute(db, route.id), 404); const restored = routes.restoreRoute(db, route.id); assert.equal(restored.status, 'active'); expectHttpError(() => routes.restoreRoute(db, route.id), 404); const missingRouteDir = routes.createRoute(db, { name: 'Ohne Verzeichnis' }, uploadedFile('missing-dir.gpx', GPX)); fs.rmSync(storage.routeDirectory(missingRouteDir.id), { recursive: true, force: true }); expectHttpError(() => routes.softDeleteRoute(db, missingRouteDir.id), 409); assert.equal(storage.removeStoredFile(null), false); assert.equal(storage.removeStoredFile('active/routes/not-there.bin'), false); expectHttpError(() => storage.removeStoredFile('../outside'), 500); expectHttpError(() => storage.resolveStoredFile('trash/routes/file'), 404); expectHttpError(() => storage.resolveStoredFile('active/../../outside'), 500); expectHttpError(() => storage.resolveStoredFile('active/routes/no-file'), 404); const orphanUpload = uploadedFile('orphan.bin', 'x'); storage.removeUpload(orphanUpload); assert.equal(fs.existsSync(orphanUpload.path), false); } finally { db.close(); } }); test('upload inspection handles JSON, GPX, binary validation and cleanup paths', async () => { const noFileReq = { body: {} }; assert.equal(await invokeUpload(inspectUpload('picture', 'picture'), noFileReq), undefined); const jsonPictureReq = { body: { picture: { filename: ' strange name.png ', dataUrl: `data:image/png;base64,${PNG_FILE.toString('base64')}` } } }; assert.equal(await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), jsonPictureReq), undefined); assert.equal(jsonPictureReq.file.detectedMime, 'image/png'); assert.equal(jsonPictureReq.file.detectedExtension, 'png'); assert.equal(jsonPictureReq.body.picture, undefined); storage.removeUpload(jsonPictureReq.file); const jsonAudioReq = { body: { audio: { filename: 'audio.bin', base64: MP3_FILE.toString('base64').replace(/(.{40})/g, '$1\n') } } }; assert.equal(await invokeUpload(inspectUpload('audio', 'audio', { allowJson: true }), jsonAudioReq), undefined); assert.equal(jsonAudioReq.file.detectedMime, 'audio/mpeg'); storage.removeUpload(jsonAudioReq.file); for (const descriptor of [ 'not-object', { base64: '' }, { base64: '!!!' }, { dataUrl: 12 }, { dataUrl: 'not-a-data-url' } ]) { const req = { body: { picture: descriptor } }; const error = await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), req); assert.equal(error.status, 400); assert.equal(req.file, undefined); } const unsupportedReq = { body: { picture: { filename: 'text.txt', base64: Buffer.from('plain text').toString('base64') } } }; const unsupported = await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), unsupportedReq); assert.equal(unsupported.status, 415); assert.equal(unsupportedReq.file, undefined); const validGpxReq = { body: {}, file: uploadedFile('route.xml', `\uFEFF${GPX}`) }; assert.equal(await invokeUpload(inspectUpload('gpx', 'gpx'), validGpxReq), undefined); assert.equal(validGpxReq.file.detectedExtension, 'gpx'); storage.removeUpload(validGpxReq.file); const invalidGpxReq = { body: {}, file: uploadedFile('route.xml', '') }; const invalidGpx = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidGpxReq); assert.equal(invalidGpx.status, 415); assert.equal(invalidGpxReq.file, undefined); const invalidUtf8Req = { body: {}, file: uploadedFile('route.gpx', Buffer.from([0xff, 0xfe, 0xfd])) }; const invalidUtf8 = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidUtf8Req); assert.equal(invalidUtf8.status, 415); assert.equal(invalidUtf8Req.file, undefined); }); test('HTTP error middleware preserves details, maps upload errors and delegates sent headers', () => { const notFound = []; notFoundHandler({}, {}, error => notFound.push(error)); assert.equal(notFound[0].status, 404); const sent = []; const response = { headersSent: false, statusCode: null, status(value) { this.statusCode = value; return this; }, json(value) { sent.push(value); return this; } }; errorHandler(new HttpError(422, 'Ungültig', { field: 'name' }), {}, response, () => {}); assert.equal(response.statusCode, 422); assert.deepEqual(sent[0].details, { field: 'name' }); errorHandler(Object.assign(new Error('large'), { code: 'LIMIT_FILE_SIZE' }), {}, response, () => {}); assert.equal(response.statusCode, 413); errorHandler(Object.assign(new Error('unexpected'), { code: 'LIMIT_UNEXPECTED_FILE' }), {}, response, () => {}); assert.equal(response.statusCode, 400); const delegated = []; errorHandler(new Error('already sent'), {}, { headersSent: true }, error => delegated.push(error)); assert.equal(delegated[0].message, 'already sent'); }); test('Express API router executes route, POI, file and restore handlers in-process', async t => { const db = openDatabase(path.join(config.dataDir, 'router.sqlite')); const app = express(); app.use(express.json({ limit: config.maxJsonBodyBytes, type: ['application/json', 'application/*+json', 'text/json'] })); app.use(express.urlencoded({ extended: true })); app.use('/api', createApiRouter(db)); app.use(notFoundHandler); app.use(errorHandler); const server = await new Promise((resolve, reject) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); listener.once('error', reject); }); const address = server.address(); const baseUrl = `http://127.0.0.1:${address.port}`; t.after(async () => { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); db.close(); }); async function json(url, options = {}, status = 200) { const response = await fetch(`${baseUrl}${url}`, options); const body = await response.json(); assert.equal(response.status, status, JSON.stringify(body)); return { response, body }; } const health = await json('/api/health'); assert.equal(health.body.ok, true); assert.match(health.body.socket, /:/); const createForm = new FormData(); createForm.append('name', 'Router-Test'); createForm.append('description', 'API-Abdeckung'); createForm.append('gpx', new Blob([GPX], { type: 'text/plain' }), 'route.txt'); const created = await json('/api/routes', { method: 'POST', body: createForm }, 201); const routeId = created.body.id; assert.equal(created.response.headers.get('location'), `/api/routes/${routeId}`); assert.equal((await json('/api/routes?lat=invalid&lon=&radiusKm=invalid')).body.routes.length, 1); assert.equal((await json(`/api/routes/${routeId}`)).body.id, routeId); assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/gpx`)).status, 200); assert.equal((await json(`/api/routes/${routeId}/pois`)).body.pois.length, 0); const updateForm = new FormData(); updateForm.append('name', 'Router-Test aktualisiert'); const updated = await json(`/api/routes/${routeId}`, { method: 'PUT', body: updateForm }); assert.equal(updated.body.name, 'Router-Test aktualisiert'); const appendForm = new FormData(); appendForm.append('gpx', new Blob([GPX_APPEND], { type: 'application/octet-stream' }), 'append.bin'); assert.equal((await json(`/api/routes/${routeId}/append`, { method: 'POST', body: appendForm })).body.pointCount, 3); const poiCreated = await json(`/api/routes/${routeId}/pois`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Router-POI', lat: 50, lon: 10, triggerRadiusM: 40, sequence: 0 }) }, 201); const poiId = poiCreated.body.id; assert.equal(poiCreated.response.headers.get('location'), `/api/routes/${routeId}/pois/${poiId}`); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`)).body.title, 'Router-POI'); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Router-POI neu' }) })).body.title, 'Router-POI neu'); const pictureForm = new FormData(); pictureForm.append('caption', 'Router-Bild'); pictureForm.append('picture', new Blob([PNG_FILE], { type: 'text/plain' }), 'picture.txt'); const pictureCreated = await json(`/api/routes/${routeId}/pois/${poiId}/pictures`, { method: 'POST', body: pictureForm }, 201); const pictureId = pictureCreated.body.id; assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures`)).body.pictures.length, 1); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}?metadata=true`)).body.id, pictureId); assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`)).status, 200); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: 'Router-Bild neu' }) })).body.caption, 'Router-Bild neu'); const audioForm = new FormData(); audioForm.append('audio', new Blob([MP3_FILE], { type: 'text/plain' }), 'audio.txt'); const audioCreated = await json(`/api/routes/${routeId}/pois/${poiId}/audio`, { method: 'POST', body: audioForm }, 201); assert.equal(audioCreated.response.headers.get('location'), `/api/routes/${routeId}/pois/${poiId}/audio`); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio?metadata=true`)).body.poiId, poiId); assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/pois/${poiId}/audio`)).status, 200); const replaceAudio = { audio: { filename: 'replacement.mp3', base64: MP3_FILE.toString('base64') } }; assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(replaceAudio) })).body.poiId, poiId); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio`, { method: 'DELETE' })).body.deleted, true); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`, { method: 'DELETE' })).body.deleted, true); assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`, { method: 'DELETE' })).body.deleted, true); const deleted = await json(`/api/routes/${routeId}`, { method: 'DELETE' }); assert.equal(deleted.body.softDeleted, true); const restored = await json(`/api/routes/${routeId}/restore`, { method: 'POST' }); assert.equal(restored.body.restored, true); await json('/api/not-found', {}, 404); });