added image and audio REST endpoints
This commit is contained in:
+30
-1
@@ -25,6 +25,15 @@ test('complete REST API documentation is kept outside the README', async () => {
|
||||
'POST /api/routes/:id/append',
|
||||
'POST /api/routes/:id/pois',
|
||||
'PUT /api/pois/:id',
|
||||
'GET /api/routes/:id/pictures',
|
||||
'GET /api/routes/:id/pictures/:pictureId',
|
||||
'POST /api/routes/:id/pictures',
|
||||
'PUT /api/routes/:id/pictures/:pictureId',
|
||||
'DELETE /api/routes/:id/pictures/:pictureId',
|
||||
'GET /api/routes/:id/audio',
|
||||
'POST /api/routes/:id/audio',
|
||||
'PUT /api/routes/:id/audio',
|
||||
'DELETE /api/routes/:id/audio',
|
||||
'DELETE /api/routes/:id',
|
||||
'POST /api/routes/:id/restore'
|
||||
]) {
|
||||
@@ -34,8 +43,28 @@ test('complete REST API documentation is kept outside the README', async () => {
|
||||
for (const parameter of [
|
||||
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
|
||||
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
|
||||
'audio', 'images'
|
||||
'picture', 'pictureId', 'caption', 'audio', 'poiId'
|
||||
]) {
|
||||
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('POI media uploads use dedicated 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\/:id\/pictures', upload\.single\('picture'\)/);
|
||||
assert.match(router, /put\('\/routes\/:id\/pictures\/:pictureId', upload\.single\('picture'\)/);
|
||||
assert.match(router, /delete\('\/routes\/:id\/pictures\/:pictureId'/);
|
||||
assert.match(router, /post\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
|
||||
assert.match(router, /put\('\/routes\/:id\/audio', upload\.single\('audio'\)/);
|
||||
assert.match(router, /delete\('\/routes\/:id\/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.match(mediaService, /pictureApiUrl/);
|
||||
assert.match(mediaService, /audioApiUrl/);
|
||||
assert.doesNotMatch(router, /audio\/:poiId/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
server.close(error => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}, expectedStatus = 200) {
|
||||
const response = await fetch(url, options);
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, expectedStatus, JSON.stringify(body));
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function requestBytes(url, expectedStatus = 200) {
|
||||
const response = await fetch(url);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(response.status, expectedStatus, bytes.toString());
|
||||
return { response, bytes };
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, output) {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (child.exitCode != null) throw new Error(`Server wurde vorzeitig beendet.\n${output.join('')}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Server startet noch.
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`);
|
||||
}
|
||||
|
||||
test('pictures and per-POI audio are delivered and managed through REST API resources', { timeout: 30000 }, async t => {
|
||||
const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-'));
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const output = [];
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(port),
|
||||
DATA_DIR: path.join(runtime, 'data'),
|
||||
STORAGE_DIR: path.join(runtime, 'storage')
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
child.stdout.on('data', chunk => output.push(chunk.toString()));
|
||||
child.stderr.on('data', chunk => output.push(chunk.toString()));
|
||||
|
||||
t.after(async () => {
|
||||
if (child.exitCode == null) {
|
||||
child.kill('SIGTERM');
|
||||
await new Promise(resolve => child.once('exit', resolve));
|
||||
}
|
||||
await fs.rm(runtime, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, child, output);
|
||||
|
||||
const gpx = await fs.readFile(path.join(root, 'examples', 'sample-route.gpx'));
|
||||
const routeForm = new FormData();
|
||||
routeForm.append('name', 'Medien-API-Test');
|
||||
routeForm.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
|
||||
const { body: route } = await requestJson(`${baseUrl}/api/routes`, {
|
||||
method: 'POST',
|
||||
body: routeForm
|
||||
}, 201);
|
||||
|
||||
const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Teststation',
|
||||
description: 'Station für Medientests',
|
||||
lat: route.start.lat,
|
||||
lon: route.start.lon,
|
||||
triggerRadiusM: 40,
|
||||
sequence: 0
|
||||
})
|
||||
}, 201);
|
||||
|
||||
const pictureForm = new FormData();
|
||||
pictureForm.append('poiId', String(poi.id));
|
||||
pictureForm.append('caption', 'Erste Bildbeschreibung');
|
||||
pictureForm.append('sequence', '0');
|
||||
pictureForm.append('picture', new Blob(['picture-one'], { type: 'image/png' }), 'bild.png');
|
||||
const { response: pictureResponse, body: picture } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/pictures`,
|
||||
{ method: 'POST', body: pictureForm },
|
||||
201
|
||||
);
|
||||
assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||
assert.equal(picture.poiId, poi.id);
|
||||
assert.equal(picture.caption, 'Erste Bildbeschreibung');
|
||||
|
||||
const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`);
|
||||
assert.equal(pictureList.pictures.length, 1);
|
||||
assert.equal(pictureList.pictures[0].id, picture.id);
|
||||
assert.equal(pictureList.pictures[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||
|
||||
const { response: pictureContentResponse, bytes: pictureBytes } = await requestBytes(
|
||||
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`
|
||||
);
|
||||
assert.equal(pictureContentResponse.headers.get('content-type'), 'image/png');
|
||||
assert.equal(pictureBytes.toString(), 'picture-one');
|
||||
|
||||
const { body: updatedPicture } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caption: 'Aktualisierte Bildbeschreibung', sequence: 2 })
|
||||
}
|
||||
);
|
||||
assert.equal(updatedPicture.caption, 'Aktualisierte Bildbeschreibung');
|
||||
assert.equal(updatedPicture.sequence, 2);
|
||||
|
||||
const audioForm = new FormData();
|
||||
audioForm.append('poiId', String(poi.id));
|
||||
audioForm.append('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3');
|
||||
const { response: audioResponse, body: audio } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/audio`,
|
||||
{ method: 'POST', body: audioForm },
|
||||
201
|
||||
);
|
||||
assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
||||
assert.equal(audio.poiId, poi.id);
|
||||
|
||||
const duplicateAudio = new FormData();
|
||||
duplicateAudio.append('poiId', String(poi.id));
|
||||
duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3');
|
||||
await requestJson(`${baseUrl}/api/routes/${route.id}/audio`, {
|
||||
method: 'POST',
|
||||
body: duplicateAudio
|
||||
}, 409);
|
||||
|
||||
const replacementAudio = new FormData();
|
||||
replacementAudio.append('audio', new Blob(['audio-two'], { type: 'audio/ogg' }), 'ansage-neu.ogg');
|
||||
const { body: replacedAudio } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`,
|
||||
{ method: 'PUT', body: replacementAudio }
|
||||
);
|
||||
assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
||||
|
||||
const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
|
||||
assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
|
||||
assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pictures/${picture.id}`);
|
||||
assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio?poiId=${poi.id}`);
|
||||
|
||||
const { response: audioContentResponse, bytes: audioBytes } = await requestBytes(
|
||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`
|
||||
);
|
||||
assert.equal(audioContentResponse.headers.get('content-type'), 'audio/ogg');
|
||||
assert.equal(audioBytes.toString(), 'audio-two');
|
||||
|
||||
const { body: deletedAudio } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
assert.equal(deletedAudio.deleted, true);
|
||||
|
||||
const { body: deletedPicture } = await requestJson(
|
||||
`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
assert.equal(deletedPicture.deleted, true);
|
||||
|
||||
await requestJson(`${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, {}, 404);
|
||||
await requestJson(`${baseUrl}/api/routes/${route.id}/audio?poiId=${poi.id}`, {}, 404);
|
||||
});
|
||||
@@ -152,7 +152,8 @@ test('reaching a POI preloads audio without autoplay and uses the vibration wrap
|
||||
const html = await read('public/index.html');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
|
||||
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl\)/);
|
||||
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl \? ns\.Api\.audioUrl/);
|
||||
assert.match(app, /ns\.Api\.pictureUrl/);
|
||||
assert.doesNotMatch(app, /AudioPlayer\.play\(/);
|
||||
assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/);
|
||||
assert.doesNotMatch(app, /navigator\.vibrate/);
|
||||
@@ -161,3 +162,19 @@ test('reaching a POI preloads audio without autoplay and uses the vibration wrap
|
||||
assert.match(html, /<audio id="poi-audio" controls preload="auto" hidden>/);
|
||||
assert.match(loader, /'vibration'/);
|
||||
});
|
||||
|
||||
|
||||
test('POI page uses icon-based back and slideshow controls in a single responsive row', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = await read('public/css/app.css');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
|
||||
assert.match(html, /class="back-button"[\s\S]*images\/icons\/back\.svg/);
|
||||
assert.match(html, /id="slide-prev"[\s\S]*images\/icons\/chevron-left\.svg/);
|
||||
assert.match(html, /id="slide-next"[\s\S]*images\/icons\/chevron-right\.svg/);
|
||||
assert.match(html, /class="slide-meta"/);
|
||||
assert.match(css, /#slide-controls[\s\S]*grid-template-columns:\s*var\(--icon-button-size\) minmax\(0, 1fr\) var\(--icon-button-size\)/);
|
||||
assert.match(css, /\.slide-meta[\s\S]*justify-content:\s*center/);
|
||||
assert.match(css, /#slide-caption[\s\S]*overflow-wrap:\s*anywhere/);
|
||||
assert.doesNotMatch(loader, /controlgroup/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user