From 5c85a0194b2cfdc931ea8542f984b9b9cfb711f5 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 17 Jun 2026 01:14:19 +0200 Subject: [PATCH] /pictures und /audio endpoints moved under /poi --- .gitignore | 3 - docs/REST-API.md | 165 +++++++++++++-------------------- package-lock.json | 4 +- package.json | 2 +- src/routes/api.js | 67 +++++++------ src/services/media-service.js | 147 +++++++++++------------------ src/services/routes-service.js | 6 +- test/api-docs.test.js | 38 ++++---- test/media-api.test.js | 60 ++++++++---- 9 files changed, 225 insertions(+), 267 deletions(-) diff --git a/.gitignore b/.gitignore index b59730c..ec5ad8d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,3 @@ storage/trash/routes/* !storage/trash/routes/.gitkeep .env npm-debug.log* - -nginx/ -systemd/ diff --git a/docs/REST-API.md b/docs/REST-API.md index d589ab0..0e86fba 100644 --- a/docs/REST-API.md +++ b/docs/REST-API.md @@ -129,13 +129,13 @@ Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`. "lon": 13.407, "triggerRadiusM": 60, "sequence": 2, - "audioUrl": "/api/routes/1/audio/7", + "audioUrl": "/api/routes/1/pois/7/audio", "images": [ { "id": 15, "caption": "Blick auf die Baumkrone", "sequence": 0, - "url": "/api/routes/1/pictures/15" + "url": "/api/routes/1/pois/7/pictures/15" } ] } @@ -151,7 +151,7 @@ Beispiele verwenden überwiegend Route `1`, POI `7` und Bild `15`. "poiTitle": "Die alte Eiche", "caption": "Blick auf die Baumkrone", "sequence": 0, - "url": "/api/routes/1/pictures/15", + "url": "/api/routes/1/pois/7/pictures/15", "createdAt": "2026-06-16 12:30:00" } ``` @@ -165,7 +165,7 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou "routeId": 1, "poiId": 7, "poiTitle": "Die alte Eiche", - "url": "/api/routes/1/audio/7", + "url": "/api/routes/1/pois/7/audio", "updatedAt": "2026-06-16 12:35:00" } ``` @@ -187,16 +187,15 @@ Pro POI kann höchstens eine Audiodatei existieren. Deshalb wird die Audioressou | `GET` | `/api/pois/:id` | einzelnen POI lesen | | `POST` | `/api/routes/:id/pois` | POI-Metadaten anlegen | | `PUT` | `/api/pois/:id` | POI-Metadaten aktualisieren | -| `GET` | `/api/routes/:id/pictures` | Bilder einer Route auflisten | -| `GET` | `/api/routes/:id/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` | -| `POST` | `/api/routes/:id/pictures` | einzelnes Bild hochladen | -| `PUT` | `/api/routes/:id/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren | -| `DELETE` | `/api/routes/:id/pictures/:pictureId` | einzelnes Bild löschen | -| `GET` | `/api/routes/:id/audio` | Audiodateien einer Route auflisten | -| `GET` | `/api/routes/:id/audio/:poiId` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` | -| `POST` | `/api/routes/:id/audio` | Audiodatei für einen POI anlegen | -| `PUT` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs ersetzen | -| `DELETE` | `/api/routes/:id/audio/:poiId` | Audiodatei eines POIs löschen | +| `GET` | `/api/routes/:routeId/pois/:poiId/pictures` | Bilder eines POIs auflisten | +| `GET` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei ausliefern; optional Metadaten mit `?metadata=true` | +| `POST` | `/api/routes/:routeId/pois/:poiId/pictures` | einzelnes Bild für den POI hochladen | +| `PUT` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | Bilddatei oder Metadaten aktualisieren | +| `DELETE` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | einzelnes Bild löschen | +| `GET` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei ausliefern; optional Metadaten mit `?metadata=true` | +| `POST` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei für den POI anlegen | +| `PUT` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs ersetzen | +| `DELETE` | `/api/routes/:routeId/pois/:poiId/audio` | Audiodatei des POIs löschen | ## 4. Systemzustand @@ -417,29 +416,24 @@ curl -X PUT http://127.0.0.1:47145/api/pois/7 \ }' ``` -## 8. Bilder einzeln verwalten +## 8. Bilder eines POIs einzeln verwalten -### `GET /api/routes/:id/pictures` +Die Route und der POI sind Bestandteil jedes Bildpfades. Dadurch ist die Zuordnung eindeutig und beim Upload muss keine zusätzliche `poiId` übergeben werden. -Liefert alle Bilder der Route. Optional kann auf einen POI eingeschränkt werden. - -#### Query-Parameter +### Gemeinsame Pfadparameter | Parameter | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `poiId` | positive Ganzzahl | nein | liefert nur Bilder dieses POIs | +| `routeId` | positive Ganzzahl | ja | ID der aktiven Route | +| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört | +| `pictureId` | positive Ganzzahl | nur bei Einzelressourcen | ID des Bildes, das zu diesem POI gehört | -Alle Bilder der Route: +### `GET /api/routes/:routeId/pois/:poiId/pictures` + +Liefert alle Bilder des angegebenen POIs in Diashow-Reihenfolge. ```bash -curl http://127.0.0.1:47145/api/routes/1/pictures -``` - -Nur Bilder des POIs `7`: - -```bash -curl --get http://127.0.0.1:47145/api/routes/1/pictures \ - --data-urlencode 'poiId=7' +curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures ``` ```json @@ -452,28 +446,28 @@ curl --get http://127.0.0.1:47145/api/routes/1/pictures \ "poiTitle": "Die alte Eiche", "caption": "Blick auf die Baumkrone", "sequence": 0, - "url": "/api/routes/1/pictures/15", + "url": "/api/routes/1/pois/7/pictures/15", "createdAt": "2026-06-16 12:30:00" } ] } ``` -### `GET /api/routes/:id/pictures/:pictureId` +### `GET /api/routes/:routeId/pois/:poiId/pictures/:pictureId` Liefert standardmäßig die Bilddatei direkt aus. Genau dieser Pfad wird im Feld `url` der Bildressource und unter `pois[].images[].url` ausgegeben. Bild speichern: ```bash -curl http://127.0.0.1:47145/api/routes/1/pictures/15 \ +curl http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ --output bild-15.jpg ``` Metadaten statt Dateidaten abrufen: ```bash -curl --get http://127.0.0.1:47145/api/routes/1/pictures/15 \ +curl --get http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ --data-urlencode 'metadata=true' ``` @@ -481,57 +475,54 @@ curl --get http://127.0.0.1:47145/api/routes/1/pictures/15 \ |---|---|---:|---:|---| | `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Bilddatei liefern | -### `POST /api/routes/:id/pictures` +Die API antwortet mit `404`, wenn Route, POI oder Bild nicht zusammengehören. -Lädt genau ein Bild hoch und ordnet es einem POI derselben Route zu. +### `POST /api/routes/:routeId/pois/:poiId/pictures` + +Lädt genau ein Bild für den im Pfad angegebenen POI hoch. Content-Type: `multipart/form-data` | Feld | Typ | Pflicht | Standard | Beschreibung | |---|---|---:|---:|---| | `picture` | Datei | ja | – | JPEG-, PNG- oder WebP-Datei | -| `poiId` | positive Ganzzahl | ja | – | Ziel-POI derselben Route | | `caption` | Text | nein | leer | sichtbare Bildbeschreibung und Grundlage für den Alternativtext | | `sequence` | nichtnegative Ganzzahl | nein | nächster freier Wert des POIs | Reihenfolge in der Diashow | ```bash -curl -X POST http://127.0.0.1:47145/api/routes/1/pictures \ - -F 'poiId=7' \ +curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/pictures \ -F 'caption=Blick auf die Baumkrone' \ -F 'sequence=0' \ -F 'picture=@eiche.jpg;type=image/jpeg' ``` -Erfolg: `201 Created` und `Location: /api/routes/1/pictures/`. +Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/pictures/`. -### `PUT /api/routes/:id/pictures/:pictureId` +### `PUT /api/routes/:routeId/pois/:poiId/pictures/:pictureId` -Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten. +Aktualisiert Metadaten und kann optional die Datei ersetzen. Nicht übergebene Metadaten bleiben erhalten. Ein Bild kann über diesen Endpunkt nicht einem anderen POI zugeordnet werden; dafür muss es beim bisherigen POI gelöscht und beim Ziel-POI neu angelegt werden. | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| | `picture` | Datei | nein | ersetzt die bisherige Bilddatei | -| `poiId` | positive Ganzzahl | nein | ordnet das Bild einem anderen POI derselben Route zu | | `caption` | Text | nein | neue Bildbeschreibung; leerer Text entfernt die Beschreibung | | `sequence` | nichtnegative Ganzzahl | nein | neue Position in der Diashow | Nur Metadaten per JSON ändern: ```bash -curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \ +curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ -H 'Content-Type: application/json' \ -d '{ - "poiId": 7, "caption": "Nahaufnahme der Eichenblätter", "sequence": 1 }' ``` -Datei und alle Metadaten ersetzen: +Datei und Metadaten ersetzen: ```bash -curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \ - -F 'poiId=7' \ +curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 \ -F 'caption=Neue Aufnahme der Eiche' \ -F 'sequence=2' \ -F 'picture=@eiche-neu.webp;type=image/webp' @@ -539,12 +530,12 @@ curl -X PUT http://127.0.0.1:47145/api/routes/1/pictures/15 \ Wird eine Datei ersetzt, entfernt der Server die bisherige Datei nach erfolgreicher Datenbankaktualisierung. -### `DELETE /api/routes/:id/pictures/:pictureId` +### `DELETE /api/routes/:routeId/pois/:poiId/pictures/:pictureId` Entfernt Bilddatensatz und Datei. ```bash -curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15 +curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/pictures/15 ``` ```json @@ -556,58 +547,32 @@ curl -X DELETE http://127.0.0.1:47145/api/routes/1/pictures/15 } ``` -## 9. Audiodateien einzeln verwalten +## 9. Audiodatei eines POIs verwalten -Pro POI ist höchstens eine Audiodatei vorgesehen. Deshalb bildet die `poiId` den Schlüssel der Audioressource. +Pro POI ist höchstens eine Audiodatei vorgesehen. Daher ist `/audio` selbst die Einzelressource; eine zusätzliche Audio-ID ist nicht erforderlich. -### `GET /api/routes/:id/audio` - -Liefert alle vorhandenen Audiodateien der Route. POIs ohne Audio werden nicht ausgegeben. - -#### Query-Parameter +### Gemeinsame Pfadparameter | Parameter | Typ | Pflicht | Beschreibung | |---|---|---:|---| -| `poiId` | positive Ganzzahl | nein | schränkt die Liste auf einen POI ein | +| `routeId` | positive Ganzzahl | ja | ID der aktiven Route | +| `poiId` | positive Ganzzahl | ja | ID eines POIs, der zu dieser Route gehört | -```bash -curl http://127.0.0.1:47145/api/routes/1/audio -``` +### `GET /api/routes/:routeId/pois/:poiId/audio` -```bash -curl --get http://127.0.0.1:47145/api/routes/1/audio \ - --data-urlencode 'poiId=7' -``` - -```json -{ - "audio": [ - { - "routeId": 1, - "poiId": 7, - "poiTitle": "Die alte Eiche", - "url": "/api/routes/1/audio/7", - "updatedAt": "2026-06-16 12:35:00" - } - ] -} -``` - -### `GET /api/routes/:id/audio/:poiId` - -Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` einer Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb einer Audiodatei springen können. +Liefert standardmäßig die Audiodatei des POIs direkt aus. Genau dieser Pfad wird in `audioUrl` und im Feld `url` der Audioressource ausgegeben. Der Endpunkt unterstützt HTTP-Range-Anfragen, damit Browser innerhalb der Audiodatei springen können. Audiodatei speichern: ```bash -curl http://127.0.0.1:47145/api/routes/1/audio/7 \ +curl http://127.0.0.1:47145/api/routes/1/pois/7/audio \ --output ansage-7.mp3 ``` Metadaten statt Dateidaten abrufen: ```bash -curl --get http://127.0.0.1:47145/api/routes/1/audio/7 \ +curl --get http://127.0.0.1:47145/api/routes/1/pois/7/audio \ --data-urlencode 'metadata=true' ``` @@ -615,30 +580,28 @@ curl --get http://127.0.0.1:47145/api/routes/1/audio/7 \ |---|---|---:|---:|---| | `metadata` | Boolean | nein | `false` | bei `true` JSON-Metadaten statt der Audiodatei liefern | -Antwortet mit `404`, wenn der POI keine Audiodatei besitzt. +Antwortet mit `404`, wenn Route und POI nicht zusammengehören oder der POI keine Audiodatei besitzt. -### `POST /api/routes/:id/audio` +### `POST /api/routes/:routeId/pois/:poiId/audio` -Legt die Audiodatei eines POIs an. +Legt die Audiodatei des im Pfad angegebenen POIs an. Content-Type: `multipart/form-data` | Feld | Typ | Pflicht | Beschreibung | |---|---|---:|---| | `audio` | Datei | ja | MP3-, MP4/M4A-, AAC-, Ogg-, WAV- oder WebM-Datei | -| `poiId` | positive Ganzzahl | ja | POI derselben Route | ```bash -curl -X POST http://127.0.0.1:47145/api/routes/1/audio \ - -F 'poiId=7' \ +curl -X POST http://127.0.0.1:47145/api/routes/1/pois/7/audio \ -F 'audio=@ansage.mp3;type=audio/mpeg' ``` -Erfolg: `201 Created` und `Location: /api/routes/1/audio/7`. +Erfolg: `201 Created` und `Location: /api/routes/1/pois/7/audio`. Existiert bereits eine Audiodatei, antwortet der Server mit `409`. Zum Ersetzen ist `PUT` zu verwenden. -### `PUT /api/routes/:id/audio/:poiId` +### `PUT /api/routes/:routeId/pois/:poiId/audio` Ersetzt die vorhandene Audiodatei des POIs. @@ -647,18 +610,18 @@ Ersetzt die vorhandene Audiodatei des POIs. | `audio` | Datei | ja | neue Audiodatei | ```bash -curl -X PUT http://127.0.0.1:47145/api/routes/1/audio/7 \ +curl -X PUT http://127.0.0.1:47145/api/routes/1/pois/7/audio \ -F 'audio=@ansage-neu.ogg;type=audio/ogg' ``` Der Server entfernt die bisherige Datei nach erfolgreicher Aktualisierung. Besitzt der POI noch keine Audiodatei, antwortet der Server mit `404`; zum erstmaligen Anlegen ist `POST` zu verwenden. -### `DELETE /api/routes/:id/audio/:poiId` +### `DELETE /api/routes/:routeId/pois/:poiId/audio` Entfernt die Audiodatei und setzt `audioUrl` des POIs auf `null`. ```bash -curl -X DELETE http://127.0.0.1:47145/api/routes/1/audio/7 +curl -X DELETE http://127.0.0.1:47145/api/routes/1/pois/7/audio ``` ```json @@ -706,8 +669,8 @@ Die API veröffentlicht keine internen Speicherpfade und keine `/media/...`-Adre ```text GET /api/routes/1/gpx -GET /api/routes/1/pictures/15 -GET /api/routes/1/audio/7 +GET /api/routes/1/pois/7/pictures/15 +GET /api/routes/1/pois/7/audio ``` Die Zuordnung lautet: @@ -715,9 +678,9 @@ Die Zuordnung lautet: | JSON-Feld | Datei-Endpunkt | |---|---| | `route.gpxUrl` | `/api/routes/:id/gpx` | -| `poi.audioUrl` | `/api/routes/:id/audio/:poiId` | -| `poi.images[].url` | `/api/routes/:id/pictures/:pictureId` | -| `picture.url` | `/api/routes/:id/pictures/:pictureId` | -| `audio.url` | `/api/routes/:id/audio/:poiId` | +| `poi.audioUrl` | `/api/routes/:routeId/pois/:poiId/audio` | +| `poi.images[].url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | +| `picture.url` | `/api/routes/:routeId/pois/:poiId/pictures/:pictureId` | +| `audio.url` | `/api/routes/:routeId/pois/:poiId/audio` | Die Dateinamen und relativen Pfade unter `storage/` bleiben ausschließlich interne Implementierungsdetails. Dateien gelöschter Routen liegen im Papierkorb und sind über keinen Datei-Endpunkt erreichbar. diff --git a/package-lock.json b/package-lock.json index a253ca9..41b878e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wegwichtel-next", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wegwichtel-next", - "version": "0.9.0", + "version": "0.10.0", "hasInstallScript": true, "dependencies": { "express": "5.2.1", diff --git a/package.json b/package.json index 3930f4c..eb15a80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wegwichtel-next", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.", "type": "module", diff --git a/src/routes/api.js b/src/routes/api.js index 7dff1ac..cffc4d1 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -7,7 +7,7 @@ import { } from '../services/routes-service.js'; import { listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture, - listAudio, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio + getAudio, getAudioFile, createAudio, updateAudio, deleteAudio } from '../services/media-service.js'; const numberOrUndefined = value => { @@ -90,41 +90,52 @@ export function createApiRouter(db) { 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/:routeId/pois/:poiId/pictures', (req, res) => { + res.json({ pictures: listPictures(db, Number(req.params.routeId), Number(req.params.poiId)) }); }); - api.get('/routes/:id/pictures/:pictureId', (req, res) => { - const routeId = Number(req.params.id); + api.get('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => { + const routeId = Number(req.params.routeId); + const poiId = Number(req.params.poiId); const pictureId = Number(req.params.pictureId); if (req.query.metadata === 'true') { - res.json(getPicture(db, routeId, pictureId)); + res.json(getPicture(db, routeId, poiId, pictureId)); return; } - sendFileResource(res, getPictureFile(db, routeId, pictureId)); + sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId)); }); - api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => { - const picture = createPicture(db, Number(req.params.id), req.body, req.file); + api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), (req, res) => { + const routeId = Number(req.params.routeId); + const poiId = Number(req.params.poiId); + const picture = createPicture(db, routeId, poiId, req.body, req.file); res.status(201) - .location(`/api/routes/${req.params.id}/pictures/${picture.id}`) + .location(`/api/routes/${routeId}/pois/${poiId}/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.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), (req, res) => { + res.json(updatePicture( + db, + Number(req.params.routeId), + Number(req.params.poiId), + 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.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => { + res.json(deletePicture( + db, + Number(req.params.routeId), + Number(req.params.poiId), + Number(req.params.pictureId) + )); }); - api.get('/routes/:id/audio', (req, res) => { - res.json({ audio: listAudio(db, Number(req.params.id), { poiId: req.query.poiId }) }); - }); - - api.get('/routes/:id/audio/:poiId', (req, res) => { - const routeId = Number(req.params.id); + api.get('/routes/:routeId/pois/:poiId/audio', (req, res) => { + const routeId = Number(req.params.routeId); const poiId = Number(req.params.poiId); if (req.query.metadata === 'true') { res.json(getAudio(db, routeId, poiId)); @@ -133,19 +144,21 @@ export function createApiRouter(db) { sendFileResource(res, getAudioFile(db, routeId, poiId)); }); - api.post('/routes/:id/audio', upload.single('audio'), (req, res) => { - const audio = createAudio(db, Number(req.params.id), req.body, req.file); + api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => { + const routeId = Number(req.params.routeId); + const poiId = Number(req.params.poiId); + const audio = createAudio(db, routeId, poiId, req.file); res.status(201) - .location(`/api/routes/${req.params.id}/audio/${audio.poiId}`) + .location(`/api/routes/${routeId}/pois/${poiId}/audio`) .json(audio); }); - api.put('/routes/:id/audio/:poiId', upload.single('audio'), (req, res) => { - res.json(updateAudio(db, Number(req.params.id), Number(req.params.poiId), req.file)); + api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), (req, res) => { + res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file)); }); - api.delete('/routes/:id/audio/:poiId', (req, res) => { - res.json(deleteAudio(db, Number(req.params.id), Number(req.params.poiId))); + api.delete('/routes/:routeId/pois/:poiId/audio', (req, res) => { + res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId))); }); api.delete('/routes/:id', (req, res) => { diff --git a/src/services/media-service.js b/src/services/media-service.js index ddd7b74..ab7a58f 100644 --- a/src/services/media-service.js +++ b/src/services/media-service.js @@ -24,16 +24,8 @@ 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 pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`; -const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`; - -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; -} +const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`; +const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`; function optionalSequence(value, fallback) { if (value == null || value === '') return fallback; @@ -44,15 +36,9 @@ function optionalSequence(value, fallback) { 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 + SELECT p.id, p.route_id, p.title, p.audio_path, p.updated_at FROM pois p JOIN routes r ON r.id = p.route_id WHERE p.id = ? AND p.route_id = ? AND r.status = 'active' @@ -85,7 +71,7 @@ function pictureFromRow(row) { poiTitle: row.poi_title, caption: row.caption, sequence: row.sequence, - url: pictureUrl(row.route_id, row.id), + url: pictureUrl(row.route_id, row.poi_id, row.id), createdAt: row.created_at }; } @@ -98,40 +84,42 @@ const pictureSelect = ` 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 listPictures(db, routeId, poiId) { + poiOnRoute(db, routeId, poiId); + return db.prepare(`${pictureSelect} + WHERE p.route_id = ? AND p.id = ? AND r.status = 'active' + ORDER BY i.sequence, i.id`) + .all(routeId, poiId) + .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); +function pictureRow(db, routeId, poiId, pictureId) { + poiOnRoute(db, routeId, poiId); + const row = db.prepare(`${pictureSelect} + WHERE p.route_id = ? AND p.id = ? AND i.id = ? AND r.status = 'active'`) + .get(routeId, poiId, pictureId); + if (!row) throw new HttpError(404, 'Bild für diesen POI nicht gefunden.'); + return row; } -export function getPictureFile(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.'); +export function getPicture(db, routeId, poiId, pictureId) { + return pictureFromRow(pictureRow(db, routeId, poiId, pictureId)); +} + +export function getPictureFile(db, routeId, poiId, pictureId) { + const row = pictureRow(db, routeId, poiId, pictureId); return { absolutePath: resolveStoredFile(row.path), filename: path.basename(row.path) }; } -export function createPicture(db, routeId, fields, file) { +export function createPicture(db, routeId, poiId, fields, file) { let relativePath; try { - activeRoute(db, routeId); + poiOnRoute(db, routeId, poiId); 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 ?? ''); @@ -140,7 +128,7 @@ export function createPicture(db, routeId, fields, file) { 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)); + return getPicture(db, routeId, poiId, Number(result.lastInsertRowid)); } catch (error) { if (relativePath) removeStoredFile(relativePath); else removeUpload(file); @@ -148,17 +136,13 @@ export function createPicture(db, routeId, fields, file) { } } -export function updatePicture(db, routeId, pictureId, fields, file) { +export function updatePicture(db, routeId, poiId, 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); + const existing = getPicture(db, routeId, poiId, pictureId); + const row = pictureRow(db, routeId, poiId, 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); @@ -169,11 +153,11 @@ export function updatePicture(db, routeId, pictureId, fields, file) { 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); + db.prepare('UPDATE poi_images SET path = ?, caption = ?, sequence = ? WHERE id = ? AND poi_id = ?') + .run(newPath, caption, sequence, pictureId, poiId); if (file && newPath !== oldPath) removeStoredFile(oldPath); - return getPicture(db, routeId, pictureId); + return getPicture(db, routeId, poiId, pictureId); } catch (error) { if (file) { if (newPath && newPath !== oldPath) removeStoredFile(newPath); @@ -183,56 +167,36 @@ export function updatePicture(db, routeId, pictureId, fields, file) { } } -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); +export function deletePicture(db, routeId, poiId, pictureId) { + const picture = getPicture(db, routeId, poiId, pictureId); + const row = pictureRow(db, routeId, poiId, pictureId); + db.prepare('DELETE FROM poi_images WHERE id = ? AND poi_id = ?').run(pictureId, poiId); removeStoredFile(row.path); return { id: picture.id, routeId: picture.routeId, poiId: picture.poiId, deleted: true }; } -function audioFromRow(row) { +function audioFromPoi(poi) { return { - routeId: row.route_id, - poiId: row.poi_id, - poiTitle: row.poi_title, - url: audioUrl(row.route_id, row.poi_id), - updatedAt: row.updated_at + routeId: poi.route_id, + poiId: poi.id, + poiTitle: poi.title, + url: audioUrl(poi.route_id, poi.id), + updatedAt: poi.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); + const poi = poiOnRoute(db, routeId, poiId); + if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.'); + return audioFromPoi(poi); } export function getAudioFile(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.'); + const poi = poiOnRoute(db, routeId, poiId); + if (!poi.audio_path) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.'); return { - absolutePath: resolveStoredFile(row.audio_path), - filename: path.basename(row.audio_path) + absolutePath: resolveStoredFile(poi.audio_path), + filename: path.basename(poi.audio_path) }; } @@ -240,7 +204,6 @@ 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; @@ -254,8 +217,8 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting } 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); + db.prepare('UPDATE pois SET audio_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?') + .run(relativePath, poiId, routeId); if (oldPath && oldPath !== relativePath) removeStoredFile(oldPath); return getAudio(db, routeId, poiId); @@ -266,8 +229,7 @@ function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting } } } -export function createAudio(db, routeId, fields, file) { - const poiId = requirePositiveInteger(fields.poiId, 'poiId'); +export function createAudio(db, routeId, poiId, file) { return storeAudio(db, routeId, poiId, file, { requireAbsent: true, requireExisting: false }); } @@ -278,7 +240,8 @@ export function updateAudio(db, routeId, poiId, file) { 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); + db.prepare('UPDATE pois SET audio_path = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND route_id = ?') + .run(poiId, routeId); removeStoredFile(poi.audio_path); return { routeId: audio.routeId, poiId: audio.poiId, deleted: true }; } diff --git a/src/services/routes-service.js b/src/services/routes-service.js index 2f6c8f5..1d94659 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -12,8 +12,8 @@ import { 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 routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`; -const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`; -const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`; +const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`; +const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`; function rowToRoute(row, includeDeleted = false) { if (!row || (!includeDeleted && row.status !== 'active')) return null; return { @@ -146,7 +146,7 @@ export function listPois(db, routeId) { id: image.id, caption: image.caption, sequence: image.sequence, - url: pictureUrl(poi.route_id, image.id) + url: pictureUrl(poi.route_id, poi.id, image.id) })) })); } diff --git a/test/api-docs.test.js b/test/api-docs.test.js index 89c1261..7890fa6 100644 --- a/test/api-docs.test.js +++ b/test/api-docs.test.js @@ -26,16 +26,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', - 'GET /api/routes/:id/audio/:poiId', - 'POST /api/routes/:id/audio', - 'PUT /api/routes/:id/audio/:poiId', - 'DELETE /api/routes/:id/audio/: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' ]) { @@ -51,21 +50,24 @@ test('complete REST API documentation is kept outside the README', async () => { } }); -test('POI media uploads use dedicated single-resource endpoints', async () => { +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\/: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\/:poiId', upload\.single\('audio'\)/); - assert.match(router, /delete\('\/routes\/:id\/audio\/:poiId'/); - assert.match(router, /post\('\/routes\/:id\/pois', upload\.none\(\)/); + 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\//); diff --git a/test/media-api.test.js b/test/media-api.test.js index 4e01901..d0676b1 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -102,32 +102,50 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 }) }, 201); + const { body: otherPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Andere Teststation', + lat: route.start.lat + 0.0001, + lon: route.start.lon + 0.0001, + triggerRadiusM: 40, + sequence: 1 + }) + }, 201); + const pictureForm = new FormData(); - pictureForm.append('poiId', String(poi.id)); + pictureForm.append('poiId', String(otherPoi.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`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`, { method: 'POST', body: pictureForm }, 201 ); - assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pictures/${picture.id}`); + assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`); assert.equal(picture.poiId, poi.id); assert.equal(picture.caption, 'Erste Bildbeschreibung'); - assert.equal(picture.url, `/api/routes/${route.id}/pictures/${picture.id}`); + assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`); const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`); assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/); assert.equal(pictureFile.toString(), 'picture-one'); const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`); assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung'); + assert.equal(pictureMetadata.poiId, poi.id); + await requestJson( + `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${picture.id}?metadata=true`, + {}, + 404 + ); - const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pictures?poiId=${poi.id}`); + const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`); assert.equal(pictureList.pictures.length, 1); assert.equal(pictureList.pictures[0].id, picture.id); const { body: updatedPicture } = await requestJson( - `${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -138,16 +156,15 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 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`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'POST', body: audioForm }, 201 ); - assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/audio/${poi.id}`); + assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/audio`); assert.equal(audio.poiId, poi.id); - assert.equal(audio.url, `/api/routes/${route.id}/audio/${poi.id}`); + assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`); const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`); assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/); assert.equal(audioFile.toString(), 'audio-one'); @@ -155,9 +172,8 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 assert.equal(audioMetadata.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`, { + await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'POST', body: duplicateAudio }, 409); @@ -165,10 +181,10 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 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/${poi.id}`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'PUT', body: replacementAudio } ); - assert.equal(replacedAudio.url, `/api/routes/${route.id}/audio/${poi.id}`); + assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`); const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`); assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/ogg/); assert.equal(replacedAudioFile.toString(), 'audio-two'); @@ -176,24 +192,28 @@ test('pictures and audio are managed as individual REST resources', { timeout: 3 const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`); assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung'); assert.equal(routeWithMedia.gpxUrl, `/api/routes/${route.id}/gpx`); - assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/audio/${poi.id}`); - 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}/pois/${poi.id}/audio`); + assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`); assert.doesNotMatch(JSON.stringify(routeWithMedia), /\/media\//); const legacyMediaResponse = await fetch(`${baseUrl}/media/routes/${route.id}/route.gpx`); assert.equal(legacyMediaResponse.status, 404); + const legacyPictureEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/pictures`); + assert.equal(legacyPictureEndpoint.status, 404); + const legacyAudioEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/audio`); + assert.equal(legacyAudioEndpoint.status, 404); const { body: deletedAudio } = await requestJson( - `${baseUrl}/api/routes/${route.id}/audio/${poi.id}`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, { method: 'DELETE' } ); assert.equal(deletedAudio.deleted, true); const { body: deletedPicture } = await requestJson( - `${baseUrl}/api/routes/${route.id}/pictures/${picture.id}`, + `${baseUrl}/api/routes/${route.id}/pois/${poi.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/${poi.id}`, {}, 404); + await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404); + await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404); });