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, '..'); const read = relativePath => fs.readFile(path.join(root, relativePath), 'utf8'); 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'); assert.match(readme, /\[.*REST-API.*\]\(docs\/REST-API\.md\)/i); assert.doesNotMatch(readme, /^## REST-API$/m); assert.doesNotMatch(readme, /curl .*\/api\//); 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/pois/:id', 'POST /api/routes', 'PUT /api/routes/:id', 'POST /api/routes/:id/append', 'POST /api/routes/:id/pois', 'PUT /api/pois/:id', '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 parameter of [ 'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug', 'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence', 'picture', 'pictureId', 'caption', 'audio', 'poiId', 'metadata' ]) { assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`); } }); 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'); assert.match(router, /post\('\/routes\/:routeId\/pois\/:poiId\/pictures', upload\.single\('picture'\)/); assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId', upload\.single\('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'\)/); assert.match(router, /put\('\/routes\/:routeId\/pois\/:poiId\/audio', upload\.single\('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.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\//); });