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, /