261 lines
9.9 KiB
JavaScript
261 lines
9.9 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';
|
|
|
|
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();
|
|
}
|
|
});
|