refactor tests
This commit is contained in:
+94
-71
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user