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);
});