renew schema and tests for new dtabases
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user