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); });