directory cleanup for tests
Sonarqube Scanner / Prepare environment (push) Successful in 11s
Sonarqube Scanner / Build and test (push) Successful in 29s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m25s
Sonarqube Scanner / Deploy to Server (push) Successful in 7s

This commit is contained in:
Florian Zumpe
2026-10-04 08:12:08 +02:00
parent 4e7b519275
commit 497b7b8050
6 changed files with 120 additions and 48 deletions
+15 -1
View File
@@ -96,13 +96,27 @@ database/schema.sql SQLite-Schema
data/ lokale SQLite-Datei data/ lokale SQLite-Datei
storage/active/routes/ aktive GPX-, Bild- und Audiodateien storage/active/routes/ aktive GPX-, Bild- und Audiodateien
storage/trash/routes/ zum Löschen markierte Strecken storage/trash/routes/ zum Löschen markierte Strecken
examples/ Beispiel-GPX
test/ automatisierte Tests test/ automatisierte Tests
test-support/ isolierte Testhilfen und synthetische GPX-Testdaten
tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit
``` ```
## Automatisierte Tests ## 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. 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. 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.
+8 -4
View File
@@ -2,10 +2,14 @@
set -euo pipefail set -euo pipefail
BASE_URL="${1:-https://wegwichtel.de/api}" BASE_URL="${1:-https://wegwichtel.de/api}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" GPX_FILE="${2:-}"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" JSON_FILE="${3:-}"
GPX_FILE="$ROOT_DIR/examples/presentation-route.gpx"
JSON_FILE="$ROOT_DIR/examples/presentation-route.json" if [[ ! -f "$GPX_FILE" || ! -f "$JSON_FILE" ]]; then
echo "Aufruf: $0 <API-Adresse> <GPX-Datei> <JSON-Datei>" >&2
echo "Gib vorhandene Dateien an. Die Beispielstrecken werden nicht mehr mitgeliefert." >&2
exit 2
fi
for command in curl python3; do for command in curl python3; do
command -v "$command" >/dev/null 2>&1 || { command -v "$command" >/dev/null 2>&1 || {
+10
View File
@@ -0,0 +1,10 @@
// Synthetic GPX data owned by the tests, independent of repository examples.
export const SAMPLE_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Wegwichtel Test" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Testweg</name></metadata>
<trk><name>Testweg</name><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34</ele></trkpt>
<trkpt lat="52.5208" lon="13.4070"><ele>37</ele></trkpt>
<trkpt lat="52.5216" lon="13.4090"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
+8 -28
View File
@@ -1,33 +1,13 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import { parseGpx } from '../src/services/gpx.js'; import { parseGpx } from '../src/services/gpx.js';
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
test('parseGpx liest Beispielstrecke', () => { test('parseGpx reads synthetic track data without repository example files', () => {
const parsed = parseGpx(fs.readFileSync(new URL('../examples/sample-route.gpx', import.meta.url), 'utf8')); const parsed = parseGpx(SAMPLE_GPX);
assert.equal(parsed.name, 'Beispielweg'); assert.equal(parsed.points.length, 3); assert.ok(parsed.metrics.distanceM > 0); assert.equal(parsed.name, 'Testweg');
}); assert.equal(parsed.points.length, 3);
assert.ok(parsed.metrics.distanceM > 0);
test('normalization separates waypoints and collapses stationary GPS drift', () => { assert.equal(parsed.points[0].lat, 52.5200);
const xml = '<gpx><metadata><name>Pause</name></metadata><wpt lat="51" lon="11"><name>Station</name></wpt><trk><trkseg>' assert.equal(parsed.points[2].lon, 13.4090);
+ '<trkpt lat="50" lon="10"><time>2026-10-03T10:00:00Z</time></trkpt>'
+ '<trkpt lat="50.00001" lon="10.00001"/><trkpt lat="50.00002" lon="10.00002"/>'
+ '<trkpt lat="50.001" lon="10.001"/></trkseg></trk></gpx>';
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, /<wpt /);
assert.match(result.normalizedXml, /<rtept /);
assert.doesNotMatch(result.normalizedXml, /<trkpt /);
assert.deepEqual(parseGpx(result.normalizedXml).points, result.points);
});
test('waypoint-only routes and missing-position recordings have no invented route coordinates', () => {
const waypoint = parseGpx('<gpx><wpt lat="50" lon="10"/></gpx>');
assert.equal(waypoint.points.length, 0); assert.equal(waypoint.metrics.distanceM, 0);
const empty = parseGpx('<gpx xmlns:wegwichtel="urn:wegwichtel:poi"><extensions><wegwichtel:gpsGap time="2026-10-03T10:00:00Z"/></extensions></gpx>');
assert.equal(empty.points.length, 0); assert.equal(empty.metrics.startLat, null);
assert.match(empty.normalizedXml, /gpsGap/);
assert.throws(() => parseGpx('<gpx><trk>'));
assert.throws(() => parseGpx('<gpx><trk><trkseg><trkpt lat="100" lon="10"/></trkseg></trk></gpx>'));
}); });
+4 -2
View File
@@ -1,4 +1,5 @@
import { parseGpx } from '../src/services/gpx.js'; import { parseGpx } from '../src/services/gpx.js';
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
@@ -182,8 +183,9 @@ assert result['ok'] is True
[pythonHealthCheck.stdout, pythonHealthCheck.stderr].filter(Boolean).join('\n') [pythonHealthCheck.stdout, pythonHealthCheck.stderr].filter(Boolean).join('\n')
); );
const gpxPath = path.join(root, 'examples', 'sample-route.gpx'); const gpxPath = path.join(runtime, 'test-route.gpx');
const gpx = await fs.readFile(gpxPath); const gpx = Buffer.from(SAMPLE_GPX);
await fs.writeFile(gpxPath, gpx);
const createRoute = await runProcess('python3', [ const createRoute = await runProcess('python3', [
path.join(root, 'tools/python/create_route.py'), path.join(root, 'tools/python/create_route.py'),
'--base-url', LOCAL_API_URL, '--base-url', LOCAL_API_URL,
+75 -13
View File
@@ -1,23 +1,85 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test'; 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) { async function uploadFixture(t) {
return readAppFile(relativePath); 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 () => { test('presentation uploader accepts explicit test files and posts route and stations through the API', async t => {
const gpx = await read('examples/presentation-route.gpx'); const fixture = await uploadFixture(t);
const metadata = JSON.parse(await read('examples/presentation-route.json')); const result = spawnSync('bash', [script, 'https://example.invalid/api', fixture.gpxPath, fixture.jsonPath], {
const script = await read('scripts/upload-presentation-route.sh'); 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(/<trkpt\b/g) || []).length >= 8); test('presentation uploader reports missing files before any HTTP request', async t => {
assert.doesNotMatch(gpx, /<rtept\b/); const fixture = await uploadFixture(t);
assert.ok(metadata.pois.length >= 3); for (const args of [[], ['https://example.invalid/api', fixture.gpxPath, `${fixture.jsonPath}.missing`]]) {
assert.match(script, /POST "\$BASE_URL\/routes"/); const result = spawnSync('bash', [script, ...args], { env: fixture.env, encoding: 'utf8', timeout: 10000 });
assert.match(script, /POST "\$BASE_URL\/routes\/\$ROUTE_ID\/pois"/); 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'));
}); });