From 5fe79067b0fbcfba1ad5db22606d528f82fc4516 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Sat, 3 Oct 2026 13:57:17 +0200 Subject: [PATCH] renew schema and tests for new dtabases --- AGENTS.md | 4 + GPX-Aufzeichnung.md | 18 ++++- database/schema.sql | 19 ----- src/services/gpx.js | 4 +- src/services/routes-service.js | 101 ++++++++++-------------- src/services/storage.js | 5 +- test/backend-services.test.js | 26 ++++++ test/runtime-database-integrity.test.js | 71 +++++++++++------ test/runtime-database-prestart.test.js | 49 ++++++++++++ 9 files changed, 189 insertions(+), 108 deletions(-) create mode 100644 test/runtime-database-prestart.test.js diff --git a/AGENTS.md b/AGENTS.md index d05849e..c7004ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/GPX-Aufzeichnung.md b/GPX-Aufzeichnung.md index 0ee9a41..ecee537 100644 --- a/GPX-Aufzeichnung.md +++ b/GPX-Aufzeichnung.md @@ -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 diff --git a/database/schema.sql b/database/schema.sql index 24b4485..545852a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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); diff --git a/src/services/gpx.js b/src/services/gpx.js index b23857b..90d5cbf 100644 --- a/src/services/gpx.js +++ b/src/services/gpx.js @@ -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, diff --git a/src/services/routes-service.js b/src/services/routes-service.js index bb69a7a..bd541e2 100644 --- a/src/services/routes-service.js +++ b/src/services/routes-service.js @@ -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 = {}) { diff --git a/src/services/storage.js b/src/services/storage.js index a1139c4..44a9e3b 100644 --- a/src/services/storage.js +++ b/src/services/storage.js @@ -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.'); } diff --git a/test/backend-services.test.js b/test/backend-services.test.js index e4ce756..d7a30a9 100644 --- a/test/backend-services.test.js +++ b/test/backend-services.test.js @@ -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', + '')); + 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); diff --git a/test/runtime-database-integrity.test.js b/test/runtime-database-integrity.test.js index 60e49a0..834474f 100644 --- a/test/runtime-database-integrity.test.js +++ b/test/runtime-database-integrity.test.js @@ -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(); + } +}); diff --git a/test/runtime-database-prestart.test.js b/test/runtime-database-prestart.test.js new file mode 100644 index 0000000..8c6c3c6 --- /dev/null +++ b/test/runtime-database-prestart.test.js @@ -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'), ''); + 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'), ''); + 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 }); + } +});