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'; 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 }, point_count: { type: 'INTEGER', notnull: 1 }, 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 }, 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(); assert.deepEqual(integrity, [{ integrity_check: 'ok' }], 'SQLite integrity_check ist fehlgeschlagen.'); const foreignKeyErrors = db.prepare('PRAGMA foreign_key_check').all(); assert.deepEqual(foreignKeyErrors, [], 'Die Datenbank enthält verletzte Foreign-Key-Beziehungen.'); } 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(); 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 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); assertRoutePointCounts(db); assertActiveRoutes(db); assertDeletedRoutes(db); assertMediaPaths(db); } finally { db.close(); } });