renew schema and tests for new dtabases
Sonarqube Scanner / Prepare environment (push) Successful in 10s
Sonarqube Scanner / Build and test (push) Successful in 32s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m41s
Sonarqube Scanner / Deploy to Server (push) Successful in 10s

This commit is contained in:
Florian Zumpe
2026-10-03 13:57:17 +02:00
parent edfe227641
commit 5fe79067b0
9 changed files with 189 additions and 108 deletions
+4
View File
@@ -3,6 +3,10 @@
GPX-Änderungen berücksichtigen `GPX-Aufzeichnung.md`: Stationen sind `wpt`, GPX-Änderungen berücksichtigen `GPX-Aufzeichnung.md`: Stationen sind `wpt`,
Aufzeichnungen sind `trkpt`, der Server normalisiert zu `rtept`. Fehlende Aufzeichnungen sind `trkpt`, der Server normalisiert zu `rtept`. Fehlende
Koordinaten bleiben gespeichert und bearbeitbar; keine Ersatzkoordinaten erfinden. Koordinaten bleiben gespeichert und bearbeitbar; keine Ersatzkoordinaten erfinden.
Die GPX-Dateien sind die einzige Quelle der Routenpunkte und berechneten
Routenwerte. SQLite speichert davon nur Min-/Max-Grenzen für die Näheresuche.
Schemaänderungen immer auch mit der Startprüfung an einer vorhandenen,
frisch erzeugten Testdatenbank prüfen.
Diese Vorgabe des Nutzers gilt für alle künftigen Änderungen an der Oberfläche. Diese Vorgabe des Nutzers gilt für alle künftigen Änderungen an der Oberfläche.
Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntnisse. Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntnisse.
+17 -1
View File
@@ -29,11 +29,27 @@ Breiten- und Längengrad müssen gemeinsam angegeben werden oder beide leer blei
Stationen haben eigene Felder unter „Ort ergänzen“. Stationen haben eigene Felder unter „Ort ergänzen“.
Die Serverdatenbank erlaubt `NULL` in `pois.lat` und `pois.lon`. Die Serverdatenbank erlaubt `NULL` in `pois.lat` und `pois.lon`.
`route_points` enthält ausschließlich gültige Koordinaten. Die Änderung ist in Die Tabelle `route_points` entfällt. Routenpunkte, Start und Mittelpunkt,
Weglänge, Höhengewinn und Punktzahl werden aus der gespeicherten GPX-Datei
gelesen beziehungsweise berechnet. Die Änderung ist in
`database/schema.sql` enthalten; eine bestehende Datenbank bekommt sie nicht `database/schema.sql` enthalten; eine bestehende Datenbank bekommt sie nicht
allein durch das Kopieren des Codes. Das Projekt verwendet beim Deployment allein durch das Kopieren des Codes. Das Projekt verwendet beim Deployment
die vereinbarte Neuerstellung der Datenbank, keine automatische Migration. die vereinbarte Neuerstellung der Datenbank, keine automatische Migration.
SQLite enthält weiterhin Verwaltungseinträge, Freigaben sowie Stations- und
Medienangaben. Von den berechneten Routenwerten bleiben dort ausschließlich
`min_lat`, `min_lon`, `max_lat` und `max_lon` als Suchwerte erhalten. Die
Näheresuche verwendet diese Grenzen zur Vorauswahl und berechnet anschließend
den Abstand zum Start aus der GPX-Datei. Anlegen, Ersetzen, Anhängen und Änderungen
an Stationen aktualisieren die Grenzen. Ohne Koordinaten bleiben alle vier leer.
Die Startprüfung erwartet dasselbe Schema einschließlich der optionalen
Stationskoordinaten, lehnt die entfernte Tabelle und Routenspalten ab und prüft
die Suchgrenzen gegen die GPX-Dateien. Ein zusätzlicher Test führt die Prüfung
in einem getrennten Prozess mit einer echten temporären Datenbank und Dateien
aus. Damit wird der Fall einer vorhandenen Datenbank auch beim normalen
Coverage-Lauf geprüft.
## Normalisierung auf dem Server ## Normalisierung auf dem Server
Beim Einreichen oder Ersetzen einer GPX-Datei normalisiert der Node-Dienst den Beim Einreichen oder Ersetzen einer GPX-Datei normalisiert der Node-Dienst den
-19
View File
@@ -6,17 +6,10 @@ CREATE TABLE IF NOT EXISTS routes (
school_name TEXT NOT NULL DEFAULT '', school_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deleted')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deleted')),
gpx_path TEXT NOT NULL, gpx_path TEXT NOT NULL,
start_lat REAL,
start_lon REAL,
center_lat REAL,
center_lon REAL,
min_lat REAL, min_lat REAL,
min_lon REAL, min_lon REAL,
max_lat REAL, max_lat REAL,
max_lon REAL, max_lon REAL,
distance_m REAL NOT NULL DEFAULT 0,
elevation_gain_m REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TEXT DEFAULT NULL, deleted_at TEXT DEFAULT NULL,
@@ -34,17 +27,6 @@ BEGIN
WHERE id = NEW.id; WHERE id = NEW.id;
END; END;
CREATE TABLE IF NOT EXISTS route_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id INTEGER NOT NULL REFERENCES routes(id),
sequence INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
elevation REAL,
recorded_at TEXT,
UNIQUE(route_id, sequence)
) STRICT;
CREATE TABLE IF NOT EXISTS pois ( CREATE TABLE IF NOT EXISTS pois (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id INTEGER NOT NULL REFERENCES routes(id), route_id INTEGER NOT NULL REFERENCES routes(id),
@@ -80,6 +62,5 @@ END;
CREATE INDEX IF NOT EXISTS idx_routes_status ON routes(status); CREATE INDEX IF NOT EXISTS idx_routes_status ON routes(status);
CREATE INDEX IF NOT EXISTS idx_routes_activation ON routes(status, activated_at); CREATE INDEX IF NOT EXISTS idx_routes_activation ON routes(status, activated_at);
CREATE INDEX IF NOT EXISTS idx_route_points_route ON route_points(route_id, sequence);
CREATE INDEX IF NOT EXISTS idx_pois_route ON pois(route_id, sequence); CREATE INDEX IF NOT EXISTS idx_pois_route ON pois(route_id, sequence);
CREATE INDEX IF NOT EXISTS idx_poi_images_poi ON poi_images(poi_id, sequence); CREATE INDEX IF NOT EXISTS idx_poi_images_poi ON poi_images(poi_id, sequence);
+2 -2
View File
@@ -42,7 +42,7 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
return result.map((point, sequence) => ({ ...point, sequence })); return result.map((point, sequence) => ({ ...point, sequence }));
} }
export function parseGpx(xml, replacementPoints = null) { export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
const gpx = parseXml(xml)?.gpx; const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.'); if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
const tracks = asList(gpx.trk); const tracks = asList(gpx.trk);
@@ -51,7 +51,7 @@ export function parseGpx(xml, replacementPoints = null) {
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt))); const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded)); const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
const waypoints = validPoints(asList(gpx.wpt)); const waypoints = validPoints(asList(gpx.wpt));
if (!points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.'); if (!allowEmpty && !points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
const metricPoints = points.length ? points : waypoints; const metricPoints = points.length ? points : waypoints;
const metrics = metricPoints.length ? routeMetrics(metricPoints) : { const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
startLat: null, startLon: null, centerLat: null, centerLon: null, startLat: null, startLon: null, centerLat: null, centerLon: null,
+42 -59
View File
@@ -4,7 +4,7 @@ import { transaction } from '../database.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js'; import { HttpError } from '../middleware/errors.js';
import { parseGpx, gpxWithWaypoints } from './gpx.js'; import { parseGpx, gpxWithWaypoints } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js'; import { distanceMeters } from './geo.js';
import { import {
ensureRouteDirectories, ensureRouteDirectories,
moveUploadedFile, moveUploadedFile,
@@ -16,10 +16,13 @@ import {
removeStoredFile removeStoredFile
} from './storage.js'; } from './storage.js';
const INSERT_POINT_SQL = 'INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)'; const UPDATE_BOUNDS_SQL = `UPDATE routes SET
const UPDATE_METRICS_SQL = `UPDATE routes SET min_lat=?, min_lon=?, max_lat=?, max_lon=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`;
start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?,
distance_m=?, elevation_gain_m=?, point_count=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`; function readRouteGpx(row) {
const filename = resolveStoredFile(row.gpx_path, { allowTrash: row.status === 'deleted' });
return parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true });
}
function trimEdgeHyphens(value) { function trimEdgeHyphens(value) {
let start = 0; let start = 0;
@@ -41,8 +44,9 @@ const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`; const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`; const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
function rowToRoute(row, includeDeleted = false) { function rowToRoute(row, includeDeleted = false, parsed = null) {
if (!row || (!includeDeleted && row.status !== 'active')) return null; if (!row || (!includeDeleted && row.status !== 'active')) return null;
const { metrics } = parsed ?? readRouteGpx(row);
return { return {
id: row.id, id: row.id,
slug: row.slug, slug: row.slug,
@@ -51,14 +55,14 @@ function rowToRoute(row, includeDeleted = false) {
schoolName: row.school_name, schoolName: row.school_name,
status: row.status, status: row.status,
activatedAt: row.activated_at, activatedAt: row.activated_at,
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon }, start: metrics.startLat == null ? null : { lat: metrics.startLat, lon: metrics.startLon },
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon }, center: metrics.centerLat == null ? null : { lat: metrics.centerLat, lon: metrics.centerLon },
bounds: row.min_lat == null bounds: row.min_lat == null
? null ? null
: { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon }, : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
distanceM: row.distance_m, distanceM: metrics.distanceM,
elevationGainM: row.elevation_gain_m, elevationGainM: metrics.elevationGainM,
pointCount: row.point_count, pointCount: metrics.pointCount,
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null, gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -71,26 +75,22 @@ function routeComparator(origin) {
return (left, right) => left.name.localeCompare(right.name, 'de'); return (left, right) => left.name.localeCompare(right.name, 'de');
} }
function insertRoutePoints(db, routeId, points, offset = 0) { function withinRouteBounds(row, origin, maximumDistance) {
const insert = db.prepare(INSERT_POINT_SQL); if (!origin) return true;
points.forEach((point, index) => { if (row.min_lat == null || row.min_lon == null || row.max_lat == null || row.max_lon == null) return false;
insert.run(routeId, offset + index, point.lat, point.lon, point.elevation, point.recordedAt); const nearest = {
}); lat: Math.max(row.min_lat, Math.min(row.max_lat, origin.lat)),
lon: Math.max(row.min_lon, Math.min(row.max_lon, origin.lon))
};
return distanceMeters(origin, nearest) <= maximumDistance;
} }
function updateMetrics(db, routeId, metrics) { function updateBounds(db, routeId, metrics) {
db.prepare(UPDATE_METRICS_SQL).run( db.prepare(UPDATE_BOUNDS_SQL).run(
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
metrics.minLat, metrics.minLat,
metrics.minLon, metrics.minLon,
metrics.maxLat, metrics.maxLat,
metrics.maxLon, metrics.maxLon,
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount,
routeId routeId
); );
} }
@@ -109,7 +109,6 @@ function uniqueSlug(db, source) {
function cleanupFailedRoute(db, routeId, gpxFile) { function cleanupFailedRoute(db, routeId, gpxFile) {
removeUpload(gpxFile); removeUpload(gpxFile);
if (!routeId) return; if (!routeId) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId); db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true }); fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
} }
@@ -139,22 +138,22 @@ export function listRoutes(db, {
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null; const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
const maximumDistance = radiusKm * 1000; const maximumDistance = radiusKm * 1000;
return rows return rows
.filter(row => withinRouteBounds(row, origin, maximumDistance))
.map(row => { .map(row => {
const route = rowToRoute(row, includeDeleted); const route = rowToRoute(row, includeDeleted);
const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null; const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null;
return { ...route, proximityM }; return { ...route, proximityM };
}) })
.filter(route => !origin || route.proximityM <= maximumDistance) .filter(route => !origin || (route.proximityM != null && route.proximityM <= maximumDistance))
.sort(routeComparator(origin)); .sort(routeComparator(origin));
} }
export function getRoute(db, id, includeDeleted = false) { export function getRoute(db, id, includeDeleted = false) {
const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id); const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id);
const route = rowToRoute(row, includeDeleted); if (!row || (!includeDeleted && row.status !== 'active')) throw new HttpError(404, 'Strecke nicht gefunden.');
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); const parsed = readRouteGpx(row);
const route = rowToRoute(row, includeDeleted, parsed);
const points = db.prepare(`SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt const { points } = parsed;
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
return { ...route, points, pois: listPois(db, id) }; return { ...route, points, pois: listPois(db, id) };
} }
@@ -178,28 +177,19 @@ export function createRoute(db, fields, gpxFile) {
const slug = uniqueSlug(db, fields.slug || name); const slug = uniqueSlug(db, fields.slug || name);
const metrics = parsed.metrics; const metrics = parsed.metrics;
const result = db.prepare(`INSERT INTO routes const result = db.prepare(`INSERT INTO routes
(slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon, (slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon)
min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
slug, slug,
name, name,
fields.description || '', fields.description || '',
fields.schoolName || '', fields.schoolName || '',
'pending', 'pending',
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
metrics.minLat, metrics.minLat,
metrics.minLon, metrics.minLon,
metrics.maxLat, metrics.maxLat,
metrics.maxLon, metrics.maxLon
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount
); );
const id = Number(result.lastInsertRowid); const id = Number(result.lastInsertRowid);
insertRoutePoints(db, id, parsed.points);
return id; return id;
}); });
ensureRouteDirectories(routeId); ensureRouteDirectories(routeId);
@@ -226,10 +216,6 @@ export function updateRoute(db, id, fields, gpxFile) {
fields.schoolName ?? existing.school_name, fields.schoolName ?? existing.school_name,
id id
); );
if (!parsed) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
insertRoutePoints(db, id, parsed.points);
updateMetrics(db, id, parsed.metrics);
}); });
if (gpxFile) { if (gpxFile) {
@@ -242,22 +228,18 @@ export function updateRoute(db, id, fields, gpxFile) {
export function appendRoute(db, id, gpxFile) { export function appendRoute(db, id, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.'); if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
const existing = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id); const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.'); if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8')); const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt const current = readRouteGpx(existing).points;
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence })); const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
transaction(db, () => { const target = resolveStoredFile(existing.gpx_path);
insertRoutePoints(db, id, parsed.points, current.length);
updateMetrics(db, id, routeMetrics(allPoints));
});
removeUpload(gpxFile);
const target = resolveStoredFile(db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(id).gpx_path);
const original = fs.readFileSync(target, 'utf8'); const original = fs.readFileSync(target, 'utf8');
fs.writeFileSync(target, parseGpx(original, allPoints).normalizedXml); const normalized = parseGpx(original, allPoints);
fs.writeFileSync(target, normalized.normalizedXml);
updateBounds(db, id, normalized.metrics);
removeUpload(gpxFile);
return getRoute(db, id); return getRoute(db, id);
} }
@@ -296,6 +278,7 @@ function syncRouteWaypoints(db, routeId) {
const filename = resolveStoredFile(row.gpx_path); const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8'); const xml = fs.readFileSync(filename, 'utf8');
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId))); fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
} }
function poiCoordinates(fields, existing = {}) { function poiCoordinates(fields, existing = {}) {
+3 -2
View File
@@ -84,8 +84,9 @@ export function restoreRouteDirectory(id, trashRelativePath) {
}; };
} }
export function resolveStoredFile(relativePath) { export function resolveStoredFile(relativePath, { allowTrash = false } = {}) {
if (!relativePath?.startsWith('active/')) { const inTrash = allowTrash && relativePath?.startsWith('trash/routes/');
if (!relativePath?.startsWith('active/') && !inTrash) {
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.'); throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
} }
+26
View File
@@ -95,6 +95,21 @@ test('route, POI, media and storage services cover successful and rejected lifec
assert.equal(route.name, 'Äußerst schöne Strecke'); assert.equal(route.name, 'Äußerst schöne Strecke');
assert.equal(route.pointCount, 2); assert.equal(route.pointCount, 2);
assert.ok(fs.existsSync(routes.getRouteGpxFile(db, route.id).absolutePath)); assert.ok(fs.existsSync(routes.getRouteGpxFile(db, route.id).absolutePath));
assert.equal(db.prepare("SELECT name FROM sqlite_master WHERE name='route_points'").get(), undefined);
const routeColumns = db.prepare('PRAGMA table_info(routes)').all().map(column => column.name);
for (const name of ['start_lat', 'center_lat', 'distance_m', 'elevation_gain_m', 'point_count']) {
assert.equal(routeColumns.includes(name), false);
}
const gpxFilename = routes.getRouteGpxFile(db, route.id).absolutePath;
const originalGpx = fs.readFileSync(gpxFilename, 'utf8');
fs.writeFileSync(gpxFilename, GPX_APPEND);
const fromFile = routes.getRoute(db, route.id);
assert.equal(fromFile.pointCount, 1);
assert.equal(fromFile.points[0].lat, 50.002);
assert.equal(fromFile.start.lat, 50.002);
assert.equal(fromFile.distanceM, 0);
assert.equal(routes.listRoutes(db, {})[0].pointCount, 1);
fs.writeFileSync(gpxFilename, originalGpx);
const duplicate = routes.createRoute(db, { const duplicate = routes.createRoute(db, {
name: 'Zweite Strecke', name: 'Zweite Strecke',
@@ -127,6 +142,17 @@ test('route, POI, media and storage services cover successful and rejected lifec
expectHttpError(() => routes.appendRoute(db, 999999, uploadedFile('missing.gpx', GPX)), 404); expectHttpError(() => routes.appendRoute(db, 999999, uploadedFile('missing.gpx', GPX)), 404);
const appended = routes.appendRoute(db, route.id, uploadedFile('append.gpx', GPX)); const appended = routes.appendRoute(db, route.id, uploadedFile('append.gpx', GPX));
assert.equal(appended.pointCount, 3); assert.equal(appended.pointCount, 3);
assert.equal(db.prepare('SELECT max_lat FROM routes WHERE id=?').get(route.id).max_lat, 50.002);
const indoor = routes.createRoute(db, { name: 'Drinnen' }, uploadedFile('indoor.gpx',
'<gpx xmlns:wegwichtel="urn:wegwichtel:poi"><extensions><wegwichtel:gpsGap time="2026-10-03T10:00:00Z"/></extensions></gpx>'));
const indoorStation = routes.createPoi(db, indoor.id, { title: 'Ohne GPS', lat: null, lon: null });
assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 });
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 50);
routes.deletePoi(db, indoor.id, indoorStation.id);
assert.equal(routes.getRoute(db, indoor.id).pointCount, 0);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null);
expectHttpError(() => routes.createPoi(db, 999999, { lat: 1, lon: 2 }), 404); expectHttpError(() => routes.createPoi(db, 999999, { lat: 1, lon: 2 }), 404);
expectHttpError(() => routes.createPoi(db, route.id, { lat: 'x', lon: 2 }), 400); expectHttpError(() => routes.createPoi(db, route.id, { lat: 'x', lon: 2 }), 400);
+46 -25
View File
@@ -5,6 +5,7 @@ import test from 'node:test';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { config } from '../src/config.js'; import { config } from '../src/config.js';
import { parseGpx } from '../src/services/gpx.js';
const databasePath = path.join(config.dataDir, 'wegwichtel.sqlite'); const databasePath = path.join(config.dataDir, 'wegwichtel.sqlite');
const activeRoutesDirectory = path.join(config.storageDir, 'active', 'routes'); const activeRoutesDirectory = path.join(config.storageDir, 'active', 'routes');
@@ -17,26 +18,22 @@ const EXPECTED_SCHEMA = Object.freeze({
name: { type: 'TEXT', notnull: 1 }, name: { type: 'TEXT', notnull: 1 },
status: { type: 'TEXT', notnull: 1 }, status: { type: 'TEXT', notnull: 1 },
gpx_path: { type: 'TEXT', notnull: 1 }, gpx_path: { type: 'TEXT', notnull: 1 },
point_count: { type: 'INTEGER', notnull: 1 }, min_lat: { type: 'REAL', notnull: 0 },
min_lon: { type: 'REAL', notnull: 0 },
max_lat: { type: 'REAL', notnull: 0 },
max_lon: { type: 'REAL', notnull: 0 },
created_at: { type: 'TEXT', notnull: 1 }, created_at: { type: 'TEXT', notnull: 1 },
updated_at: { type: 'TEXT', notnull: 1 }, updated_at: { type: 'TEXT', notnull: 1 },
deleted_at: { type: 'TEXT' }, deleted_at: { type: 'TEXT' },
activated_at: { type: 'TEXT' }, activated_at: { type: 'TEXT' },
trash_path: { type: 'TEXT' } trash_path: { type: 'TEXT' }
}, },
route_points: {
id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },
sequence: { type: 'INTEGER', notnull: 1 },
lat: { type: 'REAL', notnull: 1 },
lon: { type: 'REAL', notnull: 1 }
},
pois: { pois: {
id: { type: 'INTEGER', pk: 1 }, id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 }, route_id: { type: 'INTEGER', notnull: 1 },
title: { type: 'TEXT', notnull: 1 }, title: { type: 'TEXT', notnull: 1 },
lat: { type: 'REAL', notnull: 1 }, lat: { type: 'REAL', notnull: 0 },
lon: { type: 'REAL', notnull: 1 }, lon: { type: 'REAL', notnull: 0 },
trigger_radius_m: { type: 'REAL', notnull: 1 }, trigger_radius_m: { type: 'REAL', notnull: 1 },
sequence: { type: 'INTEGER', notnull: 1 }, sequence: { type: 'INTEGER', notnull: 1 },
audio_path: { type: 'TEXT' }, audio_path: { type: 'TEXT' },
@@ -132,21 +129,23 @@ function assertDatabaseIntegrity(db) {
); );
} }
function assertRoutePointCounts(db) { function assertGpxRouteStorage(db) {
const rows = db.prepare(` const tables = db.prepare('PRAGMA table_list').all();
SELECT r.id, r.point_count AS expected_count, COUNT(rp.id) AS actual_count assert.equal(tables.some(table => table.name === 'route_points'), false,
FROM routes r 'Veraltete Tabelle route_points vorhanden; Datenbank mit dem aktuellen Schema neu erstellen.');
LEFT JOIN route_points rp ON rp.route_id = r.id const columns = db.prepare('PRAGMA table_info(routes)').all().map(column => column.name);
GROUP BY r.id, r.point_count for (const column of ['start_lat', 'start_lon', 'center_lat', 'center_lon', 'distance_m', 'elevation_gain_m', 'point_count']) {
ORDER BY r.id assert.equal(columns.includes(column), false, `Veraltete Routenspalte ${column} vorhanden.`);
`).all(); }
}
for (const row of rows) { function assertRouteBounds(db) {
assert.equal( for (const route of db.prepare('SELECT id, gpx_path, min_lat, min_lon, max_lat, max_lon FROM routes').all()) {
row.actual_count, const filename = assertInsideStorage(route.gpx_path, `Strecke ${route.id} GPX`);
row.expected_count, const { metrics } = parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true });
`Strecke ${row.id}: point_count=${row.expected_count}, tatsächlich vorhanden=${row.actual_count}.` for (const [column, metric] of [['min_lat', 'minLat'], ['min_lon', 'minLon'], ['max_lat', 'maxLat'], ['max_lon', 'maxLon']]) {
); assert.equal(route[column], metrics[metric], `Strecke ${route.id}: ${column} stimmt nicht mit der GPX-Datei überein.`);
}
} }
} }
@@ -265,11 +264,33 @@ test('Laufzeitdatenbank und gespeicherte Streckendaten sind konsistent', () => {
assertTableSchema(db, tableName, columns); assertTableSchema(db, tableName, columns);
} }
assertStrictTables(db); assertStrictTables(db);
assertRoutePointCounts(db); assertGpxRouteStorage(db);
assertActiveRoutes(db); assertActiveRoutes(db);
assertDeletedRoutes(db); assertDeletedRoutes(db);
assertRouteBounds(db);
assertMediaPaths(db); assertMediaPaths(db);
} finally { } finally {
db.close(); db.close();
} }
}); });
test('fresh schema passes the start check and permits stations without coordinates', () => {
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
try {
db.exec(fs.readFileSync(path.join(config.projectRoot, 'database/schema.sql'), 'utf8'));
for (const [name, columns] of Object.entries(EXPECTED_SCHEMA)) assertTableSchema(db, name, columns);
assertStrictTables(db);
assertGpxRouteStorage(db);
db.prepare("INSERT INTO routes (name, slug, gpx_path) VALUES ('Ohne GPS', 'ohne-gps', 'test.gpx')").run();
db.prepare("INSERT INTO pois (route_id, title, lat, lon) VALUES (1, 'Drinnen', NULL, NULL)").run();
assertDatabaseIntegrity(db);
assert.equal(db.prepare('SELECT lat FROM pois').get().lat, null);
db.exec('CREATE TABLE route_points (id INTEGER)');
assert.throws(() => assertGpxRouteStorage(db), /Veraltete Tabelle/);
db.exec('DROP TABLE route_points; ALTER TABLE routes ADD COLUMN point_count INTEGER');
assert.throws(() => assertGpxRouteStorage(db), /Veraltete Routenspalte/);
} finally {
db.close();
}
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
test('prestart validates a real database, GPX bounds and nullable station coordinates', () => {
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'wegwichtel-prestart-'));
const dataDir = path.join(temporary, 'data');
const storageDir = path.join(temporary, 'storage');
fs.mkdirSync(dataDir);
const routeDir = path.join(storageDir, 'active/routes/1');
fs.mkdirSync(routeDir, { recursive: true });
fs.writeFileSync(path.join(routeDir, 'route.gpx'), '<gpx><rte><rtept lat="50" lon="10"/></rte></gpx>');
const db = new DatabaseSync(path.join(dataDir, 'wegwichtel.sqlite'));
const environment = { ...process.env, DATA_DIR: dataDir, STORAGE_DIR: storageDir };
delete environment.NODE_TEST_CONTEXT;
const check = () => spawnSync(process.execPath, ['--test', 'test/runtime-database-integrity.test.js'], {
cwd: root, encoding: 'utf8', env: environment
});
try {
db.exec(fs.readFileSync(path.join(root, 'database/schema.sql'), 'utf8'));
db.prepare(`INSERT INTO routes (name, slug, gpx_path, min_lat, min_lon, max_lat, max_lon)
VALUES ('Test', 'test', 'active/routes/1/route.gpx', 50, 10, 50, 10)`).run();
db.prepare("INSERT INTO pois (route_id, title, lat, lon) VALUES (1, 'Ohne GPS', NULL, NULL)").run();
let result = check();
assert.equal(result.status, 0, result.stdout + result.stderr);
db.exec('UPDATE routes SET min_lat=49');
result = check();
assert.notEqual(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout + result.stderr, /min_lat stimmt nicht mit der GPX-Datei/);
db.exec('UPDATE routes SET min_lat=50');
fs.writeFileSync(path.join(routeDir, 'route.gpx'), '<gpx><rte>');
result = check();
assert.notEqual(result.status, 0);
assert.match(result.stdout + result.stderr, /kein gültiges XML/);
db.exec('DROP TABLE pois; CREATE TABLE pois (id INTEGER PRIMARY KEY, route_id INTEGER NOT NULL, title TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL) STRICT');
result = check();
assert.notEqual(result.status, 0);
assert.match(result.stdout + result.stderr, /Falsche NOT NULL-Eigenschaft für pois.lat/);
} finally {
db.close();
fs.rmSync(temporary, { recursive: true, force: true });
}
});