Files
Wegwichtel/test/runtime-database-integrity.test.js
T
Florian Zumpe 5fe79067b0
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
renew schema and tests for new dtabases
2026-10-03 13:57:17 +02:00

297 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
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');
const trashRoutesDirectory = path.join(config.storageDir, 'trash', 'routes');
const EXPECTED_SCHEMA = Object.freeze({
routes: {
id: { type: 'INTEGER', pk: 1 },
slug: { type: 'TEXT', notnull: 1 },
name: { type: 'TEXT', notnull: 1 },
status: { type: 'TEXT', notnull: 1 },
gpx_path: { type: 'TEXT', 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' }
},
pois: {
id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },
title: { type: 'TEXT', 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' },
created_at: { type: 'TEXT', notnull: 1 },
updated_at: { type: 'TEXT', notnull: 1 }
},
poi_images: {
id: { type: 'INTEGER', pk: 1 },
poi_id: { type: 'INTEGER', notnull: 1 },
path: { type: 'TEXT', notnull: 1 },
caption: { type: 'TEXT', notnull: 1 },
sequence: { type: 'INTEGER', notnull: 1 },
created_at: { type: 'TEXT', notnull: 1 }
}
});
function assertInsideStorage(relativePath, description) {
assert.equal(typeof relativePath, 'string', `${description}: Pfad fehlt.`);
assert.ok(relativePath.length > 0, `${description}: Pfad ist leer.`);
assert.equal(path.isAbsolute(relativePath), false, `${description}: Pfad muss relativ zu STORAGE_DIR sein.`);
const storageRoot = path.resolve(config.storageDir);
const target = path.resolve(storageRoot, relativePath);
assert.ok(
target.startsWith(`${storageRoot}${path.sep}`),
`${description}: Pfad liegt außerhalb von STORAGE_DIR: ${relativePath}`
);
return target;
}
function assertFileExists(relativePath, description) {
const target = assertInsideStorage(relativePath, description);
assert.ok(fs.existsSync(target), `${description}: Datei fehlt: ${relativePath}`);
assert.ok(fs.statSync(target).isFile(), `${description}: Erwartete Datei ist keine Datei: ${relativePath}`);
}
function assertDirectoryExists(relativePath, description) {
const target = assertInsideStorage(relativePath, description);
assert.ok(fs.existsSync(target), `${description}: Verzeichnis fehlt: ${relativePath}`);
assert.ok(fs.statSync(target).isDirectory(), `${description}: Erwarteter Pfad ist kein Verzeichnis: ${relativePath}`);
}
function assertTableSchema(db, tableName, expectedColumns) {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
assert.ok(columns.length > 0, `Tabelle ${tableName} fehlt.`);
const byName = new Map(columns.map(column => [column.name, column]));
for (const [columnName, expected] of Object.entries(expectedColumns)) {
const actual = byName.get(columnName);
assert.ok(actual, `Spalte ${tableName}.${columnName} fehlt.`);
assert.equal(String(actual.type).toUpperCase(), expected.type, `Falscher Typ für ${tableName}.${columnName}.`);
if (Object.hasOwn(expected, 'notnull')) {
assert.equal(actual.notnull, expected.notnull, `Falsche NOT NULL-Eigenschaft für ${tableName}.${columnName}.`);
}
if (Object.hasOwn(expected, 'pk')) {
assert.equal(actual.pk, expected.pk, `Falsche PRIMARY KEY-Eigenschaft für ${tableName}.${columnName}.`);
}
}
}
function assertStrictTables(db) {
const tableList = db.prepare('PRAGMA table_list').all();
const byName = new Map(tableList.map(table => [table.name, table]));
for (const tableName of Object.keys(EXPECTED_SCHEMA)) {
const table = byName.get(tableName);
assert.ok(table, `Tabelle ${tableName} fehlt in PRAGMA table_list.`);
assert.equal(table.strict, 1, `Tabelle ${tableName} ist nicht STRICT.`);
}
}
function assertDatabaseIntegrity(db) {
const integrity = db.prepare('PRAGMA integrity_check').all();
const integrityMessages = integrity.map(row => String(row.integrity_check));
assert.equal(
integrity.length,
1,
`SQLite integrity_check hat unerwartete Ergebnisse geliefert: ${integrityMessages.join('; ')}`
);
assert.equal(
integrity[0]?.integrity_check,
'ok',
`SQLite integrity_check ist fehlgeschlagen: ${integrityMessages.join('; ')}`
);
const foreignKeyErrors = db.prepare('PRAGMA foreign_key_check').all();
assert.equal(
foreignKeyErrors.length,
0,
`Die Datenbank enthält verletzte Foreign-Key-Beziehungen: ${JSON.stringify(foreignKeyErrors)}`
);
}
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.`);
}
}
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.`);
}
}
}
function assertActiveRoutes(db) {
const activeRoutes = db.prepare(`
SELECT id, gpx_path
FROM routes
WHERE status = 'active'
ORDER BY id
`).all();
const activeIds = new Set(activeRoutes.map(route => Number(route.id)));
for (const route of activeRoutes) {
const id = Number(route.id);
const expectedDirectory = `active/routes/${id}`;
const expectedGpxPath = `${expectedDirectory}/route.gpx`;
assertDirectoryExists(expectedDirectory, `Strecke ${id}`);
assert.equal(route.gpx_path, expectedGpxPath, `Strecke ${id}: gpx_path stimmt nicht mit der Strecken-ID überein.`);
assertFileExists(route.gpx_path, `Strecke ${id} GPX`);
}
if (!fs.existsSync(activeRoutesDirectory)) return;
for (const entry of fs.readdirSync(activeRoutesDirectory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
assert.match(entry.name, /^\d+$/, `Unerwartetes Streckenverzeichnis unter storage/active/routes: ${entry.name}`);
const id = Number(entry.name);
assert.ok(activeIds.has(id), `Aktives Streckenverzeichnis ${entry.name} hat keinen aktiven Datenbankeintrag.`);
}
}
function assertDeletedRoutes(db) {
const deletedRoutes = db.prepare(`
SELECT id, gpx_path, trash_path
FROM routes
WHERE status = 'deleted'
ORDER BY id
`).all();
const trashPaths = new Set();
for (const route of deletedRoutes) {
const id = Number(route.id);
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${id}: trash_path fehlt.`);
assert.ok(route.trash_path.startsWith(`trash/routes/${id}-`), `Gelöschte Strecke ${id}: trash_path passt nicht zur ID.`);
assertDirectoryExists(route.trash_path, `Gelöschte Strecke ${id}`);
assert.ok(route.gpx_path.startsWith(`${route.trash_path}/`), `Gelöschte Strecke ${id}: gpx_path liegt nicht im Papierkorbpfad.`);
assertFileExists(route.gpx_path, `Gelöschte Strecke ${id} GPX`);
trashPaths.add(route.trash_path);
}
if (!fs.existsSync(trashRoutesDirectory)) return;
for (const entry of fs.readdirSync(trashRoutesDirectory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const relativePath = `trash/routes/${entry.name}`;
assert.ok(trashPaths.has(relativePath), `Papierkorb-Streckenverzeichnis ${entry.name} hat keinen passenden Datenbankeintrag.`);
}
}
function routeStoragePrefix(route) {
if (route.status === 'active') return `active/routes/${route.id}/`;
assert.equal(route.status, 'deleted', `Strecke ${route.id}: unbekannter Status ${route.status}.`);
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${route.id}: trash_path fehlt.`);
return `${route.trash_path}/`;
}
function assertMediaPaths(db) {
const audioRows = db.prepare(`
SELECT p.id AS poi_id, p.audio_path, r.id AS route_id, r.status, r.trash_path
FROM pois p
JOIN routes r ON r.id = p.route_id
WHERE p.audio_path IS NOT NULL
ORDER BY p.id
`).all();
for (const row of audioRows) {
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
assert.ok(row.audio_path.startsWith(`${prefix}audio/`), `POI ${row.poi_id}: Audio-Pfad passt nicht zu Strecke ${row.route_id}.`);
assertFileExists(row.audio_path, `POI ${row.poi_id} Audio`);
}
const imageRows = db.prepare(`
SELECT pi.id AS image_id, pi.path, p.id AS poi_id, r.id AS route_id, r.status, r.trash_path
FROM poi_images pi
JOIN pois p ON p.id = pi.poi_id
JOIN routes r ON r.id = p.route_id
ORDER BY pi.id
`).all();
for (const row of imageRows) {
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
assert.ok(row.path.startsWith(`${prefix}images/`), `Bild ${row.image_id}: Pfad passt nicht zu Strecke ${row.route_id}.`);
assertFileExists(row.path, `Bild ${row.image_id} von POI ${row.poi_id}`);
}
}
test('Laufzeitdatenbank und gespeicherte Streckendaten sind konsistent', () => {
if (!fs.existsSync(databasePath)) {
console.log(`Keine Datenbank vorhanden (${databasePath}); Integritätsprüfung ist nicht erforderlich.`);
return;
}
assert.ok(fs.statSync(databasePath).isFile(), `Datenbankpfad ist keine Datei: ${databasePath}`);
const db = new DatabaseSync(databasePath, {
readOnly: true,
enableForeignKeyConstraints: true,
timeout: 5000
});
try {
assertDatabaseIntegrity(db);
for (const [tableName, columns] of Object.entries(EXPECTED_SCHEMA)) {
assertTableSchema(db, tableName, columns);
}
assertStrictTables(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();
}
});