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`,
Aufzeichnungen sind `trkpt`, der Server normalisiert zu `rtept`. Fehlende
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.
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“.
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
allein durch das Kopieren des Codes. Das Projekt verwendet beim Deployment
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
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 '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deleted')),
gpx_path TEXT NOT NULL,
start_lat REAL,
start_lon REAL,
center_lat REAL,
center_lon REAL,
min_lat REAL,
min_lon REAL,
max_lat 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,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TEXT DEFAULT NULL,
@@ -34,17 +27,6 @@ BEGIN
WHERE id = NEW.id;
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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_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_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 }));
}
export function parseGpx(xml, replacementPoints = null) {
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
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 points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
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 metrics = metricPoints.length ? routeMetrics(metricPoints) : {
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 { HttpError } from '../middleware/errors.js';
import { parseGpx, gpxWithWaypoints } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import { distanceMeters } from './geo.js';
import {
ensureRouteDirectories,
moveUploadedFile,
@@ -16,10 +16,13 @@ import {
removeStoredFile
} from './storage.js';
const INSERT_POINT_SQL = 'INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)';
const UPDATE_METRICS_SQL = `UPDATE routes SET
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=?`;
const UPDATE_BOUNDS_SQL = `UPDATE routes SET
min_lat=?, min_lon=?, max_lat=?, max_lon=?, 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) {
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 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;
const { metrics } = parsed ?? readRouteGpx(row);
return {
id: row.id,
slug: row.slug,
@@ -51,14 +55,14 @@ function rowToRoute(row, includeDeleted = false) {
schoolName: row.school_name,
status: row.status,
activatedAt: row.activated_at,
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
start: metrics.startLat == null ? null : { lat: metrics.startLat, lon: metrics.startLon },
center: metrics.centerLat == null ? null : { lat: metrics.centerLat, lon: metrics.centerLon },
bounds: row.min_lat == null
? null
: { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
distanceM: row.distance_m,
elevationGainM: row.elevation_gain_m,
pointCount: row.point_count,
distanceM: metrics.distanceM,
elevationGainM: metrics.elevationGainM,
pointCount: metrics.pointCount,
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -71,26 +75,22 @@ function routeComparator(origin) {
return (left, right) => left.name.localeCompare(right.name, 'de');
}
function insertRoutePoints(db, routeId, points, offset = 0) {
const insert = db.prepare(INSERT_POINT_SQL);
points.forEach((point, index) => {
insert.run(routeId, offset + index, point.lat, point.lon, point.elevation, point.recordedAt);
});
function withinRouteBounds(row, origin, maximumDistance) {
if (!origin) return true;
if (row.min_lat == null || row.min_lon == null || row.max_lat == null || row.max_lon == null) return false;
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) {
db.prepare(UPDATE_METRICS_SQL).run(
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
function updateBounds(db, routeId, metrics) {
db.prepare(UPDATE_BOUNDS_SQL).run(
metrics.minLat,
metrics.minLon,
metrics.maxLat,
metrics.maxLon,
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount,
routeId
);
}
@@ -109,7 +109,6 @@ function uniqueSlug(db, source) {
function cleanupFailedRoute(db, routeId, gpxFile) {
removeUpload(gpxFile);
if (!routeId) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
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 maximumDistance = radiusKm * 1000;
return rows
.filter(row => withinRouteBounds(row, origin, maximumDistance))
.map(row => {
const route = rowToRoute(row, includeDeleted);
const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null;
return { ...route, proximityM };
})
.filter(route => !origin || route.proximityM <= maximumDistance)
.filter(route => !origin || (route.proximityM != null && route.proximityM <= maximumDistance))
.sort(routeComparator(origin));
}
export function getRoute(db, id, includeDeleted = false) {
const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id);
const route = rowToRoute(row, includeDeleted);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
const points = db.prepare(`SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
if (!row || (!includeDeleted && row.status !== 'active')) throw new HttpError(404, 'Strecke nicht gefunden.');
const parsed = readRouteGpx(row);
const route = rowToRoute(row, includeDeleted, parsed);
const { points } = parsed;
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 metrics = parsed.metrics;
const result = db.prepare(`INSERT INTO routes
(slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon,
min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
(slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
slug,
name,
fields.description || '',
fields.schoolName || '',
'pending',
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
metrics.minLat,
metrics.minLon,
metrics.maxLat,
metrics.maxLon,
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount
metrics.maxLon
);
const id = Number(result.lastInsertRowid);
insertRoutePoints(db, id, parsed.points);
return id;
});
ensureRouteDirectories(routeId);
@@ -226,10 +216,6 @@ export function updateRoute(db, id, fields, gpxFile) {
fields.schoolName ?? existing.school_name,
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) {
@@ -242,22 +228,18 @@ export function updateRoute(db, id, fields, gpxFile) {
export function appendRoute(db, id, gpxFile) {
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.');
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
const current = readRouteGpx(existing).points;
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
transaction(db, () => {
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 target = resolveStoredFile(existing.gpx_path);
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);
}
@@ -296,6 +278,7 @@ function syncRouteWaypoints(db, routeId) {
const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8');
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
}
function poiCoordinates(fields, existing = {}) {
+3 -2
View File
@@ -84,8 +84,9 @@ export function restoreRouteDirectory(id, trashRelativePath) {
};
}
export function resolveStoredFile(relativePath) {
if (!relativePath?.startsWith('active/')) {
export function resolveStoredFile(relativePath, { allowTrash = false } = {}) {
const inTrash = allowTrash && relativePath?.startsWith('trash/routes/');
if (!relativePath?.startsWith('active/') && !inTrash) {
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.pointCount, 2);
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, {
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);
const appended = routes.appendRoute(db, route.id, uploadedFile('append.gpx', GPX));
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, 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 { config } from '../src/config.js';
import { parseGpx } from '../src/services/gpx.js';
const databasePath = path.join(config.dataDir, 'wegwichtel.sqlite');
const activeRoutesDirectory = path.join(config.storageDir, 'active', 'routes');
@@ -17,26 +18,22 @@ const EXPECTED_SCHEMA = Object.freeze({
name: { type: 'TEXT', notnull: 1 },
status: { 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 },
updated_at: { type: 'TEXT', notnull: 1 },
deleted_at: { type: 'TEXT' },
activated_at: { 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: {
id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },
title: { type: 'TEXT', notnull: 1 },
lat: { type: 'REAL', notnull: 1 },
lon: { type: 'REAL', notnull: 1 },
lat: { type: 'REAL', notnull: 0 },
lon: { type: 'REAL', notnull: 0 },
trigger_radius_m: { type: 'REAL', notnull: 1 },
sequence: { type: 'INTEGER', notnull: 1 },
audio_path: { type: 'TEXT' },
@@ -132,21 +129,23 @@ function assertDatabaseIntegrity(db) {
);
}
function assertRoutePointCounts(db) {
const rows = db.prepare(`
SELECT r.id, r.point_count AS expected_count, COUNT(rp.id) AS actual_count
FROM routes r
LEFT JOIN route_points rp ON rp.route_id = r.id
GROUP BY r.id, r.point_count
ORDER BY r.id
`).all();
function assertGpxRouteStorage(db) {
const tables = db.prepare('PRAGMA table_list').all();
assert.equal(tables.some(table => table.name === 'route_points'), false,
'Veraltete Tabelle route_points vorhanden; Datenbank mit dem aktuellen Schema neu erstellen.');
const columns = db.prepare('PRAGMA table_info(routes)').all().map(column => column.name);
for (const column of ['start_lat', 'start_lon', 'center_lat', 'center_lon', 'distance_m', 'elevation_gain_m', 'point_count']) {
assert.equal(columns.includes(column), false, `Veraltete Routenspalte ${column} vorhanden.`);
}
}
for (const row of rows) {
assert.equal(
row.actual_count,
row.expected_count,
`Strecke ${row.id}: point_count=${row.expected_count}, tatsächlich vorhanden=${row.actual_count}.`
);
function assertRouteBounds(db) {
for (const route of db.prepare('SELECT id, gpx_path, min_lat, min_lon, max_lat, max_lon FROM routes').all()) {
const filename = assertInsideStorage(route.gpx_path, `Strecke ${route.id} GPX`);
const { metrics } = parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true });
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);
}
assertStrictTables(db);
assertRoutePointCounts(db);
assertGpxRouteStorage(db);
assertActiveRoutes(db);
assertDeletedRoutes(db);
assertRouteBounds(db);
assertMediaPaths(db);
} finally {
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 });
}
});