290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
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 requestBuffer(url, expectedStatus = 200) {
|
|
const response = await fetch(url);
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
assert.equal(response.status, expectedStatus);
|
|
return { response, body };
|
|
}
|
|
|
|
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 audio are managed as individual REST 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);
|
|
assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`);
|
|
const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
|
|
assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/);
|
|
assert.deepEqual(downloadedGpx, gpx);
|
|
|
|
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 { 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 { body: fetchedPoi } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}`
|
|
);
|
|
assert.equal(fetchedPoi.id, poi.id);
|
|
assert.equal(fetchedPoi.routeId, route.id);
|
|
assert.equal(fetchedPoi.title, 'Teststation');
|
|
|
|
const { body: updatedPoi } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: 'Aktualisierte Teststation' })
|
|
}
|
|
);
|
|
assert.equal(updatedPoi.title, 'Aktualisierte Teststation');
|
|
|
|
await requestJson(`${baseUrl}/api/routes/999999/pois/${poi.id}`, {}, 404);
|
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/999999`, {}, 404);
|
|
await requestJson(`${baseUrl}/api/pois/${poi.id}`, {}, 404);
|
|
|
|
const pictureForm = new FormData();
|
|
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}/pois/${poi.id}/pictures`,
|
|
{ method: 'POST', body: pictureForm },
|
|
201
|
|
);
|
|
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}/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}/pois/${poi.id}/pictures`);
|
|
assert.equal(pictureList.pictures.length, 1);
|
|
assert.equal(pictureList.pictures[0].id, picture.id);
|
|
|
|
const jsonPicturePayload = {
|
|
caption: 'Bild aus JSON',
|
|
sequence: 1,
|
|
picture: {
|
|
filename: 'bild-json.webp',
|
|
contentType: 'image/webp',
|
|
base64: Buffer.from('picture-json').toString('base64')
|
|
}
|
|
};
|
|
const { body: jsonPicture } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/json' },
|
|
body: JSON.stringify(jsonPicturePayload)
|
|
},
|
|
201
|
|
);
|
|
assert.equal(jsonPicture.poiId, otherPoi.id);
|
|
assert.equal(jsonPicture.caption, 'Bild aus JSON');
|
|
const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
|
|
assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/webp/);
|
|
assert.equal(jsonPictureFile.toString(), 'picture-json');
|
|
|
|
await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/json' },
|
|
body: '{}'
|
|
},
|
|
400
|
|
);
|
|
|
|
const { body: updatedPicture } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.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('audio', new Blob(['audio-one'], { type: 'audio/mpeg' }), 'ansage.mp3');
|
|
const { response: audioResponse, body: audio } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
|
{ method: 'POST', body: audioForm },
|
|
201
|
|
);
|
|
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}/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');
|
|
const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
|
|
assert.equal(audioMetadata.poiId, poi.id);
|
|
|
|
const duplicateAudio = new FormData();
|
|
duplicateAudio.append('audio', new Blob(['duplicate'], { type: 'audio/mpeg' }), 'doppelt.mp3');
|
|
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
|
|
method: 'POST',
|
|
body: duplicateAudio
|
|
}, 409);
|
|
|
|
const replacementAudio = {
|
|
audio: {
|
|
filename: 'ansage-neu.ogg',
|
|
contentType: 'audio/ogg',
|
|
base64: Buffer.from('audio-two').toString('base64')
|
|
}
|
|
};
|
|
const { body: replacedAudio } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(replacementAudio)
|
|
}
|
|
);
|
|
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');
|
|
|
|
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}/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}/pois/${poi.id}/audio`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
assert.equal(deletedAudio.deleted, true);
|
|
|
|
const { body: deletedPicture } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
assert.equal(deletedPicture.deleted, true);
|
|
|
|
const { body: deletedJsonPicture } = await requestJson(
|
|
`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
assert.equal(deletedJsonPicture.deleted, true);
|
|
|
|
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);
|
|
});
|