diff --git a/README.md b/README.md index 5bf3f28..46dbe1e 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,27 @@ database/schema.sql SQLite-Schema data/ lokale SQLite-Datei storage/active/routes/ aktive GPX-, Bild- und Audiodateien storage/trash/routes/ zum Löschen markierte Strecken -examples/ Beispiel-GPX test/ automatisierte Tests +test-support/ isolierte Testhilfen und synthetische GPX-Testdaten tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit ``` ## Automatisierte Tests +GPX-Testdaten liegen in `test-support/gpx-fixtures.js`. Die API-Tests schreiben sie +bei Bedarf in ein temporäres Verzeichnis. Mitgelieferte Beispielstrecken oder +Dateien unter `examples/` werden nicht vorausgesetzt. + +Das optionale Präsentations-Uploadskript verwendet ausdrücklich angegebene Dateien: + +```bash +bash scripts/upload-presentation-route.sh https://wegwichtel.de/api /pfad/strecke.gpx /pfad/stationen.json +``` + +Die JSON-Datei enthält `name`, `description` und `pois` mit `title`, `description`, +`lat`, `lon`, `triggerRadiusM` und `sequence`. Die Skript-Tests erzeugen ihre Daten +selbst und simulieren die HTTP-Aufrufe, einschließlich des Aufräumens bei Fehlern. + Die REST-Integrationstests starten die Anwendung über `npm start` mit isolierten temporären Daten- und Speicherverzeichnissen. Der eigentliche Node.js-Prozess lauscht innerhalb des Testaufbaus auf `127.0.0.1:47146`. Davor läuft ein ausschließlich für die Tests bestimmter Reverse-Proxy auf `http://127.0.0.1:47145`, sodass dieser Loopback-Endpunkt das einzige Ziel der API-Aufrufe bleibt. Der Produktivserver wird von der Testsuite nicht angesprochen. Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. Für `POST`, `PUT`, `PATCH` und `DELETE` antwortet er zunächst mit `401 Unauthorized` und `WWW-Authenticate: Basic`, sofern kein gültiger Authorization-Header vorhanden ist. Nach erfolgreicher Basic-Auth-Prüfung entfernt der Proxy den Header wieder und leitet den Request an den lokal per NPM gestarteten Server weiter. Damit wird ein möglicher Nginx-Passwortschutz für schreibende Operationen realistisch simuliert, ohne Authentifizierung in die Anwendung selbst einzubauen. diff --git a/scripts/upload-presentation-route.sh b/scripts/upload-presentation-route.sh index 3eaf5bc..6b17398 100644 --- a/scripts/upload-presentation-route.sh +++ b/scripts/upload-presentation-route.sh @@ -2,10 +2,14 @@ set -euo pipefail BASE_URL="${1:-https://wegwichtel.de/api}" -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" -GPX_FILE="$ROOT_DIR/examples/presentation-route.gpx" -JSON_FILE="$ROOT_DIR/examples/presentation-route.json" +GPX_FILE="${2:-}" +JSON_FILE="${3:-}" + +if [[ ! -f "$GPX_FILE" || ! -f "$JSON_FILE" ]]; then + echo "Aufruf: $0 " >&2 + echo "Gib vorhandene Dateien an. Die Beispielstrecken werden nicht mehr mitgeliefert." >&2 + exit 2 +fi for command in curl python3; do command -v "$command" >/dev/null 2>&1 || { diff --git a/test-support/gpx-fixtures.js b/test-support/gpx-fixtures.js new file mode 100644 index 0000000..206f605 --- /dev/null +++ b/test-support/gpx-fixtures.js @@ -0,0 +1,10 @@ +// Synthetic GPX data owned by the tests, independent of repository examples. +export const SAMPLE_GPX = ` + + Testweg + Testweg + 34 + 37 + 35 + +`; diff --git a/test/gpx.test.js b/test/gpx.test.js index ea8f082..dc6df72 100644 --- a/test/gpx.test.js +++ b/test/gpx.test.js @@ -1,33 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { parseGpx } from '../src/services/gpx.js'; +import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js'; -test('parseGpx liest Beispielstrecke', () => { - const parsed = parseGpx(fs.readFileSync(new URL('../examples/sample-route.gpx', import.meta.url), 'utf8')); - assert.equal(parsed.name, 'Beispielweg'); assert.equal(parsed.points.length, 3); assert.ok(parsed.metrics.distanceM > 0); -}); - -test('normalization separates waypoints and collapses stationary GPS drift', () => { - const xml = 'PauseStation' - + '' - + '' - + ''; - const result = parseGpx(xml); - assert.equal(result.points.length, 2); - assert.equal(result.points[0].recordedAt, '2026-10-03T10:00:00Z'); - assert.match(result.normalizedXml, / { - const waypoint = parseGpx(''); - assert.equal(waypoint.points.length, 0); assert.equal(waypoint.metrics.distanceM, 0); - const empty = parseGpx(''); - assert.equal(empty.points.length, 0); assert.equal(empty.metrics.startLat, null); - assert.match(empty.normalizedXml, /gpsGap/); - assert.throws(() => parseGpx('')); - assert.throws(() => parseGpx('')); +test('parseGpx reads synthetic track data without repository example files', () => { + const parsed = parseGpx(SAMPLE_GPX); + assert.equal(parsed.name, 'Testweg'); + assert.equal(parsed.points.length, 3); + assert.ok(parsed.metrics.distanceM > 0); + assert.equal(parsed.points[0].lat, 52.5200); + assert.equal(parsed.points[2].lon, 13.4090); }); diff --git a/test/media-api.test.js b/test/media-api.test.js index f1cd561..b8bfa18 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -1,4 +1,5 @@ import { parseGpx } from '../src/services/gpx.js'; +import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -182,8 +183,9 @@ assert result['ok'] is True [pythonHealthCheck.stdout, pythonHealthCheck.stderr].filter(Boolean).join('\n') ); - const gpxPath = path.join(root, 'examples', 'sample-route.gpx'); - const gpx = await fs.readFile(gpxPath); + const gpxPath = path.join(runtime, 'test-route.gpx'); + const gpx = Buffer.from(SAMPLE_GPX); + await fs.writeFile(gpxPath, gpx); const createRoute = await runProcess('python3', [ path.join(root, 'tools/python/create_route.py'), '--base-url', LOCAL_API_URL, diff --git a/test/presentation-route.test.js b/test/presentation-route.test.js index dd17681..18d6840 100644 --- a/test/presentation-route.test.js +++ b/test/presentation-route.test.js @@ -1,23 +1,85 @@ -import { readAppFile } from '../test-support/i18n.js'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import test from 'node:test'; +import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js'; -const root = path.resolve(import.meta.dirname, '..'); +const script = path.resolve(import.meta.dirname, '../scripts/upload-presentation-route.sh'); -async function read(relativePath) { - return readAppFile(relativePath); +async function uploadFixture(t) { + const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-presentation-')); + t.after(() => fs.rm(runtime, { recursive: true, force: true })); + const bin = path.join(runtime, 'bin'); + await fs.mkdir(bin); + const gpxPath = path.join(runtime, 'track.gpx'); + const jsonPath = path.join(runtime, 'stations.json'); + const logPath = path.join(runtime, 'requests.jsonl'); + const metadata = { + name: 'Testweg', description: 'Synthetic upload data', + pois: [ + { title: 'Start', description: 'First station', lat: 52.5200, lon: 13.4050, triggerRadiusM: 25, sequence: 0 }, + { title: 'Ende', description: 'Last station', lat: 52.5216, lon: 13.4090, triggerRadiusM: 30, sequence: 1 } + ] + }; + await fs.writeFile(gpxPath, SAMPLE_GPX); + await fs.writeFile(jsonPath, JSON.stringify(metadata)); + // Capture curl arguments locally. No requests are sent to a real server. + await fs.writeFile(path.join(bin, 'curl'), `#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +log = Path(os.environ['TEST_REQUEST_LOG']) +count = len(log.read_text().splitlines()) if log.exists() else 0 +with log.open('a') as output: + output.write(json.dumps(sys.argv[1:]) + '\\n') +if os.environ.get('TEST_FAIL_POI') == '1' and count == 1: + sys.exit(22) +print(json.dumps({'id': 101})) +`, { mode: 0o755 }); + return { + gpxPath, jsonPath, logPath, metadata, + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, TEST_REQUEST_LOG: logPath } + }; } -test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => { - const gpx = await read('examples/presentation-route.gpx'); - const metadata = JSON.parse(await read('examples/presentation-route.json')); - const script = await read('scripts/upload-presentation-route.sh'); +test('presentation uploader accepts explicit test files and posts route and stations through the API', async t => { + const fixture = await uploadFixture(t); + const result = spawnSync('bash', [script, 'https://example.invalid/api', fixture.gpxPath, fixture.jsonPath], { + env: fixture.env, encoding: 'utf8', timeout: 10000 + }); + assert.equal(result.status, 0, result.stderr); + const requests = (await fs.readFile(fixture.logPath, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + assert.equal(requests.length, 3); + assert.ok(requests[0].includes('https://example.invalid/api/routes')); + assert.ok(requests[0].includes(`gpx=@${fixture.gpxPath};type=application/gpx+xml`)); + for (const [index, poi] of fixture.metadata.pois.entries()) { + assert.ok(requests[index + 1].includes('https://example.invalid/api/routes/101/pois')); + assert.ok(requests[index + 1].includes(`title=${poi.title}`)); + assert.ok(requests[index + 1].includes(`sequence=${poi.sequence}`)); + assert.ok(requests[index + 1].includes('POST')); + } + assert.match(result.stdout, /Server-ID: 101/); +}); - assert.ok((gpx.match(/= 8); - assert.doesNotMatch(gpx, /= 3); - assert.match(script, /POST "\$BASE_URL\/routes"/); - assert.match(script, /POST "\$BASE_URL\/routes\/\$ROUTE_ID\/pois"/); +test('presentation uploader reports missing files before any HTTP request', async t => { + const fixture = await uploadFixture(t); + for (const args of [[], ['https://example.invalid/api', fixture.gpxPath, `${fixture.jsonPath}.missing`]]) { + const result = spawnSync('bash', [script, ...args], { env: fixture.env, encoding: 'utf8', timeout: 10000 }); + assert.equal(result.status, 2); + assert.match(result.stderr, /Aufruf:/); + } + await assert.rejects(fs.access(fixture.logPath), { code: 'ENOENT' }); +}); + +test('presentation uploader removes an incomplete route if a station upload fails', async t => { + const fixture = await uploadFixture(t); + const result = spawnSync('bash', [script, 'https://example.invalid/api', fixture.gpxPath, fixture.jsonPath], { + env: { ...fixture.env, TEST_FAIL_POI: '1' }, encoding: 'utf8', timeout: 10000 + }); + assert.notEqual(result.status, 0); + const requests = (await fs.readFile(fixture.logPath, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + assert.equal(requests.length, 3); + assert.ok(requests[2].includes('DELETE')); + assert.ok(requests[2].includes('https://example.invalid/api/routes/101')); });