added image and audio REST endpoints
This commit is contained in:
parent
b910bce8df
commit
c98f304ffa
@ -1,6 +1,6 @@
|
||||
# Wegwichtel Next
|
||||
|
||||
Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können Bilder und eine Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt.
|
||||
Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können einzeln verwaltete Bilder mit Beschreibungen und eine separat verwaltete Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt.
|
||||
|
||||
## Architektur
|
||||
|
||||
@ -66,7 +66,7 @@ public/vendor/jquery-ui/jquery-ui-1.14.2.min.css
|
||||
public/vendor/jquery-ui/images/*.png
|
||||
```
|
||||
|
||||
Die Anwendung verwendet aus jQuery UI insbesondere die Widgets **Button** und **Controlgroup**. Das offizielle vollständige jQuery-UI-Bundle bleibt lokal verfügbar, damit weitere aktuelle Widgets ohne erneuten CDN-Bezug ergänzt werden können. Die frühere jQuery-Mobile-Seitensteuerung wurde durch eine eigene, History-API-basierte Navigation ersetzt.
|
||||
Die Anwendung verwendet aus jQuery UI insbesondere das Widget **Button**. Das offizielle vollständige jQuery-UI-Bundle bleibt lokal verfügbar, damit weitere aktuelle Widgets ohne erneuten CDN-Bezug ergänzt werden können. Die frühere jQuery-Mobile-Seitensteuerung wurde durch eine eigene, History-API-basierte Navigation ersetzt.
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
@ -93,7 +93,7 @@ test/ Basistests
|
||||
|
||||
## Technische Hinweise
|
||||
|
||||
- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar.
|
||||
- Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. Bild- und Audiodateien werden der Clientanwendung ausschließlich über `/api/routes/:id/pictures/:pictureId` beziehungsweise `/api/routes/:id/audio?poiId=:poiId` bereitgestellt.
|
||||
- Dateiverschiebung und Datenbankänderung sind durch eine kompensierende Rückverschiebung gekoppelt: Schlägt die SQL-Transaktion fehl, wird das Verzeichnis an seinen vorherigen Ort zurückbewegt.
|
||||
- GPX-Erweiterungen ergänzen die Trackpunkte in SQLite. Das Original-GPX bleibt im Skelett unverändert; ein späterer Exportdienst sollte aus den Datenbankpunkten eine konsolidierte GPX-Datei generieren.
|
||||
- Schreibzugriffe sind noch nicht authentifiziert. Vor einem öffentlichen Einsatz sind Rollen, Login, CSRF-Schutz, Rate-Limits, Dateisignaturprüfung und ein Moderationsworkflow zwingend zu ergänzen.
|
||||
@ -109,7 +109,7 @@ test/ Basistests
|
||||
- Benutzer-, Schul- und Projektzuordnung mit Rollenmodell
|
||||
- Offline-Cache/PWA für Wanderungen ohne Mobilfunkempfang
|
||||
- Kartenansicht, GPX-Visualisierung und Abweichungswarnung
|
||||
- Bildunterschriften, Sortierung und gezieltes Entfernen einzelner Medien
|
||||
- Administrationsoberfläche für die bereits vorhandenen Einzelendpunkte zur Medienverwaltung
|
||||
- Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist
|
||||
- Integritätsjournal für Dateiverschiebungen und Wiederherstellungen
|
||||
|
||||
|
||||
882
docs/REST-API.md
882
docs/REST-API.md
File diff suppressed because it is too large
Load Diff
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.7.1",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.7.1",
|
||||
"version": "0.9.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"express": "5.2.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.7.1",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
||||
"type": "module",
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
ns.Api = {
|
||||
health: () => request('/health'),
|
||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`),
|
||||
pictureUrl: (routeId, pictureId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/pictures/${encodeURIComponent(pictureId)}`,
|
||||
audioUrl: (routeId, poiId) => `${ns.Config.apiBase}/routes/${encodeURIComponent(routeId)}/audio?poiId=${encodeURIComponent(poiId)}`
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
|
||||
@ -261,8 +261,11 @@
|
||||
setActivePoi(poi.id);
|
||||
$('#poi-title').text(poi.title);
|
||||
$('#poi-description').text(poi.description || '');
|
||||
ns.Slideshow.show(poi.images);
|
||||
ns.AudioPlayer.load(poi.audioUrl);
|
||||
ns.Slideshow.show((poi.images || []).map(image => ({
|
||||
...image,
|
||||
url: ns.Api.pictureUrl(state.route.id, image.id)
|
||||
})));
|
||||
ns.AudioPlayer.load(poi.audioUrl ? ns.Api.audioUrl(state.route.id, poi.id) : null);
|
||||
navigate('#poi-page');
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,10 @@ import {
|
||||
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
|
||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||
} from '../services/routes-service.js';
|
||||
import {
|
||||
listPictures, getPictureContent, createPicture, updatePicture, deletePicture,
|
||||
listAudio, getAudioContent, createAudio, updateAudio, deleteAudio
|
||||
} from '../services/media-service.js';
|
||||
|
||||
const numberOrUndefined = value => {
|
||||
if (value == null || value === '') return undefined;
|
||||
@ -12,6 +16,26 @@ const numberOrUndefined = value => {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const positiveInteger = (value, name) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
const error = new Error(`${name} muss eine positive Ganzzahl sein.`);
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function sendMedia(res, resource) {
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`,
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
});
|
||||
res.type(resource.contentType);
|
||||
res.sendFile(resource.absolutePath);
|
||||
}
|
||||
|
||||
export function createApiRouter(db) {
|
||||
const api = Router();
|
||||
|
||||
@ -61,13 +85,63 @@ export function createApiRouter(db) {
|
||||
res.json(appendRoute(db, Number(req.params.id), req.file));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/pois', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
|
||||
const poi = createPoi(db, Number(req.params.id), req.body, req.files);
|
||||
api.post('/routes/:id/pois', upload.none(), (req, res) => {
|
||||
const poi = createPoi(db, Number(req.params.id), req.body);
|
||||
res.status(201).location(`/api/pois/${poi.id}`).json(poi);
|
||||
});
|
||||
|
||||
api.put('/pois/:id', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'images', maxCount: 20 }]), (req, res) => {
|
||||
res.json(updatePoi(db, Number(req.params.id), req.body, req.files));
|
||||
api.put('/pois/:id', upload.none(), (req, res) => {
|
||||
res.json(updatePoi(db, Number(req.params.id), req.body));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pictures', (req, res) => {
|
||||
res.json({ pictures: listPictures(db, Number(req.params.id), { poiId: req.query.poiId }) });
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||
sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId)));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
|
||||
const picture = createPicture(db, Number(req.params.id), req.body, req.file);
|
||||
res.status(201)
|
||||
.location(`/api/routes/${req.params.id}/pictures/${picture.id}`)
|
||||
.json(picture);
|
||||
});
|
||||
|
||||
api.put('/routes/:id/pictures/:pictureId', upload.single('picture'), (req, res) => {
|
||||
res.json(updatePicture(db, Number(req.params.id), Number(req.params.pictureId), req.body, req.file));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||
res.json(deletePicture(db, Number(req.params.id), Number(req.params.pictureId)));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/audio', (req, res) => {
|
||||
if (req.query.poiId == null || req.query.poiId === '') {
|
||||
res.json({ audio: listAudio(db, Number(req.params.id)) });
|
||||
return;
|
||||
}
|
||||
|
||||
const poiId = positiveInteger(req.query.poiId, 'poiId');
|
||||
sendMedia(res, getAudioContent(db, Number(req.params.id), poiId));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
|
||||
res.status(201)
|
||||
.location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`)
|
||||
.json(audio);
|
||||
});
|
||||
|
||||
api.put('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId');
|
||||
res.json(updateAudio(db, Number(req.params.id), poiId, req.file));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id/audio', (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId');
|
||||
res.json(deleteAudio(db, Number(req.params.id), poiId));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id', (req, res) => {
|
||||
|
||||
301
src/services/media-service.js
Normal file
301
src/services/media-service.js
Normal file
@ -0,0 +1,301 @@
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import {
|
||||
ensureRouteDirectories,
|
||||
moveUploadedFile,
|
||||
removeStoredFile,
|
||||
removeUpload,
|
||||
resolveStoredFile
|
||||
} from './storage.js';
|
||||
|
||||
const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const AUDIO_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm'
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
||||
|
||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
||||
const pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||
const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`;
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'audio/mp4',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'audio/webm'
|
||||
});
|
||||
|
||||
function contentResource(relativePath) {
|
||||
const absolutePath = resolveStoredFile(relativePath);
|
||||
return {
|
||||
absolutePath,
|
||||
contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream',
|
||||
filename: path.basename(absolutePath)
|
||||
};
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, name) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new HttpError(400, `${name} muss eine positive Ganzzahl sein.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalSequence(value, fallback) {
|
||||
if (value == null || value === '') return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new HttpError(400, 'sequence muss eine nichtnegative Ganzzahl sein.');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function activeRoute(db, routeId) {
|
||||
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
return route;
|
||||
}
|
||||
|
||||
function poiOnRoute(db, routeId, poiId) {
|
||||
const poi = db.prepare(`
|
||||
SELECT p.id, p.route_id, p.title, p.audio_path
|
||||
FROM pois p
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'
|
||||
`).get(poiId, routeId);
|
||||
if (!poi) throw new HttpError(404, 'POI auf dieser Strecke nicht gefunden.');
|
||||
return poi;
|
||||
}
|
||||
|
||||
function validateUpload(file, kind) {
|
||||
if (!file) throw new HttpError(400, `Eine ${kind === 'picture' ? 'Bilddatei' : 'Audiodatei'} ist erforderlich.`);
|
||||
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
const valid = kind === 'picture'
|
||||
? IMAGE_MIME_TYPES.has(file.mimetype) || IMAGE_EXTENSIONS.has(extension)
|
||||
: AUDIO_MIME_TYPES.has(file.mimetype) || AUDIO_EXTENSIONS.has(extension);
|
||||
|
||||
if (!valid) {
|
||||
removeUpload(file);
|
||||
throw new HttpError(415, kind === 'picture'
|
||||
? 'Der Upload ist keine unterstützte Bilddatei.'
|
||||
: 'Der Upload ist keine unterstützte Audiodatei.');
|
||||
}
|
||||
}
|
||||
|
||||
function pictureFromRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
routeId: row.route_id,
|
||||
poiId: row.poi_id,
|
||||
poiTitle: row.poi_title,
|
||||
caption: row.caption,
|
||||
sequence: row.sequence,
|
||||
url: pictureApiUrl(row.route_id, row.id),
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
const pictureSelect = `
|
||||
SELECT i.id, i.poi_id, p.route_id, p.title AS poi_title,
|
||||
i.path, i.caption, i.sequence, i.created_at
|
||||
FROM poi_images i
|
||||
JOIN pois p ON p.id = i.poi_id
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
`;
|
||||
|
||||
export function listPictures(db, routeId, { poiId } = {}) {
|
||||
activeRoute(db, routeId);
|
||||
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
|
||||
const rows = filterPoiId == null
|
||||
? db.prepare(`${pictureSelect} WHERE p.route_id = ? AND r.status = 'active' ORDER BY p.sequence, p.id, i.sequence, i.id`).all(routeId)
|
||||
: db.prepare(`${pictureSelect} WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' ORDER BY i.sequence, i.id`).all(routeId, filterPoiId);
|
||||
return rows.map(pictureFromRow);
|
||||
}
|
||||
|
||||
export function getPicture(db, routeId, pictureId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||
return pictureFromRow(row);
|
||||
}
|
||||
|
||||
export function getPictureContent(db, routeId, pictureId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||
return contentResource(row.path);
|
||||
}
|
||||
|
||||
export function createPicture(db, routeId, fields, file) {
|
||||
let relativePath;
|
||||
try {
|
||||
activeRoute(db, routeId);
|
||||
validateUpload(file, 'picture');
|
||||
|
||||
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
|
||||
poiOnRoute(db, routeId, poiId);
|
||||
const sequence = optionalSequence(fields.sequence,
|
||||
db.prepare('SELECT COALESCE(MAX(sequence), -1) + 1 AS value FROM poi_images WHERE poi_id = ?').get(poiId).value);
|
||||
const caption = String(fields.caption ?? '');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
const result = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)')
|
||||
.run(poiId, relativePath, caption, sequence);
|
||||
return getPicture(db, routeId, Number(result.lastInsertRowid));
|
||||
} catch (error) {
|
||||
if (relativePath) removeStoredFile(relativePath);
|
||||
else removeUpload(file);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePicture(db, routeId, pictureId, fields, file) {
|
||||
let newPath;
|
||||
let oldPath;
|
||||
try {
|
||||
const existing = getPicture(db, routeId, pictureId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
oldPath = row.path;
|
||||
const poiId = fields.poiId == null || fields.poiId === ''
|
||||
? existing.poiId
|
||||
: requirePositiveInteger(fields.poiId, 'poiId');
|
||||
poiOnRoute(db, routeId, poiId);
|
||||
const caption = fields.caption == null ? existing.caption : String(fields.caption);
|
||||
const sequence = optionalSequence(fields.sequence, existing.sequence);
|
||||
|
||||
newPath = row.path;
|
||||
if (file) {
|
||||
validateUpload(file, 'picture');
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
newPath = moveUploadedFile(file, path.join(root, 'images', uniqueFilename(file.originalname)));
|
||||
}
|
||||
|
||||
db.prepare('UPDATE poi_images SET poi_id = ?, path = ?, caption = ?, sequence = ? WHERE id = ?')
|
||||
.run(poiId, newPath, caption, sequence, pictureId);
|
||||
|
||||
if (file && newPath !== oldPath) removeStoredFile(oldPath);
|
||||
return getPicture(db, routeId, pictureId);
|
||||
} catch (error) {
|
||||
if (file) {
|
||||
if (newPath && newPath !== oldPath) removeStoredFile(newPath);
|
||||
else removeUpload(file);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function deletePicture(db, routeId, pictureId) {
|
||||
const picture = getPicture(db, routeId, pictureId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
db.prepare('DELETE FROM poi_images WHERE id = ?').run(pictureId);
|
||||
removeStoredFile(row.path);
|
||||
return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true };
|
||||
}
|
||||
|
||||
function audioFromRow(row) {
|
||||
return {
|
||||
routeId: row.route_id,
|
||||
poiId: row.poi_id,
|
||||
poiTitle: row.poi_title,
|
||||
url: audioApiUrl(row.route_id, row.poi_id),
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
const audioSelect = `
|
||||
SELECT p.id AS poi_id, p.route_id, p.title AS poi_title,
|
||||
p.audio_path, p.updated_at
|
||||
FROM pois p
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
`;
|
||||
|
||||
export function listAudio(db, routeId, { poiId } = {}) {
|
||||
activeRoute(db, routeId);
|
||||
const filterPoiId = poiId == null || poiId === '' ? null : requirePositiveInteger(poiId, 'poiId');
|
||||
const rows = filterPoiId == null
|
||||
? db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.audio_path IS NOT NULL AND r.status = 'active' ORDER BY p.sequence, p.id`).all(routeId)
|
||||
: db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`).all(routeId, filterPoiId);
|
||||
return rows.map(audioFromRow);
|
||||
}
|
||||
|
||||
export function getAudio(db, routeId, poiId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||
.get(routeId, poiId);
|
||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||
return audioFromRow(row);
|
||||
}
|
||||
|
||||
export function getAudioContent(db, routeId, poiId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||
.get(routeId, poiId);
|
||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||
return contentResource(row.audio_path);
|
||||
}
|
||||
|
||||
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
|
||||
let relativePath;
|
||||
let oldPath;
|
||||
try {
|
||||
activeRoute(db, routeId);
|
||||
validateUpload(file, 'audio');
|
||||
const poi = poiOnRoute(db, routeId, poiId);
|
||||
oldPath = poi.audio_path;
|
||||
|
||||
if (requireAbsent && poi.audio_path) {
|
||||
throw new HttpError(409, 'Für diesen POI ist bereits eine Audiodatei hinterlegt. Verwende PUT zum Ersetzen.');
|
||||
}
|
||||
if (requireExisting && !poi.audio_path) {
|
||||
throw new HttpError(404, 'Für diesen POI ist noch keine Audiodatei hinterlegt. Verwende POST zum Anlegen.');
|
||||
}
|
||||
|
||||
const root = ensureRouteDirectories(routeId);
|
||||
relativePath = moveUploadedFile(file, path.join(root, 'audio', uniqueFilename(file.originalname)));
|
||||
db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(relativePath, poiId);
|
||||
|
||||
if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath);
|
||||
return getAudio(db, routeId, poiId);
|
||||
} catch (error) {
|
||||
if (relativePath && relativePath !== oldPath) removeStoredFile(relativePath);
|
||||
else removeUpload(file);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAudio(db, routeId, fields, file) {
|
||||
const poiId = requirePositiveInteger(fields.poiId, 'poiId');
|
||||
return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false });
|
||||
}
|
||||
|
||||
export function updateAudio(db, routeId, poiId, file) {
|
||||
return storeAudio(db, routeId, poiId, file, { requireAbsent: false, requireExisting: true });
|
||||
}
|
||||
|
||||
export function deleteAudio(db, routeId, poiId) {
|
||||
const audio = getAudio(db, routeId, poiId);
|
||||
const poi = poiOnRoute(db, routeId, poiId);
|
||||
db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(poiId);
|
||||
removeStoredFile(poi.audio_path);
|
||||
return { routeId: audio.routeId, poiId: audio.poiId, deleted: true };
|
||||
}
|
||||
@ -1,19 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { transaction } from '../database.js';
|
||||
import { config } from '../config.js';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import { parseGpx } from './gpx.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import {
|
||||
ensureRouteDirectories, moveUploadedFile, relativeStoragePath, routeDirectory,
|
||||
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
||||
softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload
|
||||
} from './storage.js';
|
||||
|
||||
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
|
||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
||||
|
||||
function rowToRoute(row, includeDeleted = false) {
|
||||
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
||||
return {
|
||||
@ -132,8 +129,13 @@ export function listPois(db, routeId) {
|
||||
return pois.map(poi => ({
|
||||
id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description,
|
||||
lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence,
|
||||
audioUrl: safeMediaUrl(poi.audio_path),
|
||||
images: imageStatement.all(poi.id).map(image => ({ id: image.id, caption: image.caption, sequence: image.sequence, url: safeMediaUrl(image.path) }))
|
||||
audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null,
|
||||
images: imageStatement.all(poi.id).map(image => ({
|
||||
id: image.id,
|
||||
caption: image.caption,
|
||||
sequence: image.sequence,
|
||||
url: `/api/routes/${routeId}/pictures/${image.id}`
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
@ -143,49 +145,32 @@ export function getPoi(db, poiId) {
|
||||
return listPois(db, row.routeId).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
export function createPoi(db, routeId, fields, files = {}) {
|
||||
export function createPoi(db, routeId, fields) {
|
||||
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
const lat = Number.parseFloat(fields.lat); const lon = Number.parseFloat(fields.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
||||
const lat = Number.parseFloat(fields.lat);
|
||||
const lon = Number.parseFloat(fields.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
||||
}
|
||||
const result = db.prepare(`INSERT INTO pois (route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(routeId, fields.title || 'Unbenannter POI', fields.description || '', lat, lon,
|
||||
Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters), Number.parseInt(fields.sequence || '0', 10));
|
||||
const poiId = Number(result.lastInsertRowid);
|
||||
const routeRoot = ensureRouteDirectories(routeId);
|
||||
const audio = files.audio?.[0];
|
||||
if (audio) {
|
||||
const relative = moveUploadedFile(audio, path.join(routeRoot, 'audio', uniqueFilename(audio.originalname)));
|
||||
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
|
||||
}
|
||||
const insertImage = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
|
||||
for (const [index, image] of (files.images || []).entries()) {
|
||||
const relative = moveUploadedFile(image, path.join(routeRoot, 'images', uniqueFilename(image.originalname)));
|
||||
insertImage.run(poiId, relative, '', index);
|
||||
}
|
||||
return listPois(db, routeId).find(poi => poi.id === poiId);
|
||||
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function updatePoi(db, poiId, fields, files = {}) {
|
||||
export function updatePoi(db, poiId, fields) {
|
||||
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ?`).get(poiId);
|
||||
if (!existing || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
|
||||
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
|
||||
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
||||
const radius = fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM);
|
||||
const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10);
|
||||
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
|
||||
throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.');
|
||||
}
|
||||
db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
||||
.run(fields.title ?? existing.title, fields.description ?? existing.description,
|
||||
fields.lat == null ? existing.lat : Number.parseFloat(fields.lat), fields.lon == null ? existing.lon : Number.parseFloat(fields.lon),
|
||||
fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM),
|
||||
fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10), poiId);
|
||||
const root = ensureRouteDirectories(existing.route_id);
|
||||
const audio = files.audio?.[0];
|
||||
if (audio) {
|
||||
const relative = moveUploadedFile(audio, path.join(root, 'audio', uniqueFilename(audio.originalname)));
|
||||
db.prepare('UPDATE pois SET audio_path = ? WHERE id = ?').run(relative, poiId);
|
||||
}
|
||||
const maxSeq = db.prepare('SELECT COALESCE(MAX(sequence), -1) AS value FROM poi_images WHERE poi_id = ?').get(poiId).value;
|
||||
const insert = db.prepare('INSERT INTO poi_images (poi_id, path, caption, sequence) VALUES (?, ?, ?, ?)');
|
||||
for (const [index, image] of (files.images || []).entries()) {
|
||||
const relative = moveUploadedFile(image, path.join(root, 'images', uniqueFilename(image.originalname)));
|
||||
insert.run(poiId, relative, '', maxSeq + index + 1);
|
||||
}
|
||||
.run(fields.title ?? existing.title, fields.description ?? existing.description, lat, lon, radius, sequence, poiId);
|
||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,34 @@ export function removeUpload(uploaded) {
|
||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
||||
}
|
||||
|
||||
export function resolveStoredFile(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function removeStoredFile(relativePath) {
|
||||
if (!relativePath) return false;
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target)) return false;
|
||||
fs.rmSync(target, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function softDeleteRouteDirectory(id) {
|
||||
const source = routeDirectory(id);
|
||||
if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
189
test/media-api.test.js
Normal file
189
test/media-api.test.js
Normal file
@ -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/);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user