Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Application;
use OpenNIT\Core\Database;
use OpenNIT\Core\SchemaInspector;
/**
* Migrations-Smoke-Test: wendet ALLE Migrationen auf eine frische Datenbank an
* und prüft, dass
* (1) kein harter (nicht-benigner) Fehler auftritt,
* (2) danach KEIN Schema-Drift besteht (alle Tabellen/Spalten wie erwartet),
* (3) ein zweiter Lauf idempotent ist (nur benigne „already exists"/Duplicate).
*
* Nutzt den ECHTEN Splitter + die echte Benign-Fehlerliste aus Application
* (per Reflection, ohne App-Bootstrap und ohne Datei-Prune), damit der Test
* exakt das Produktionsverhalten des Migrationsläufers abbildet.
*
* Läuft nur, wenn OPENNIT_MIGRATION_SMOKE=1 gesetzt ist UND eine (leere)
* Test-Datenbank via DB_*-ENV erreichbar ist – sonst wird sauber übersprungen,
* damit `php tests/run.php` auch ohne MySQL grün bleibt (CI setzt beides).
*/
return static function (): void {
if (getenv('OPENNIT_MIGRATION_SMOKE') !== '1') {
echo " (übersprungen: OPENNIT_MIGRATION_SMOKE!=1 – kein Test-DB-Lauf)\n";
return;
}
$root = dirname(__DIR__);
// config/database.php liest $_ENV; im CLI ist das je nach variables_order
// leer – daher aus getenv() nachziehen (CI setzt die Variablen per env:).
foreach (['DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'DB_CHARSET'] as $k) {
$v = getenv($k);
if ($v !== false && ($_ENV[$k] ?? '') === '') {
$_ENV[$k] = $v;
}
}
/** @var array $dbConfig */
$dbConfig = require $root . '/config/database.php';
// Ist der Schalter gesetzt, ist der Lauf gewollt: Eine unerreichbare
// Datenbank ist dann ein Fehlschlag und kein Grund zu schweigen. Sonst
// meldete ein Lauf ohne gestartete Datenbank ein gruenes Ergebnis, ohne
// eine einzige Migration ausgefuehrt zu haben.
$db = new Database($dbConfig);
$verbindungsfehler = null;
try {
$db->getPdo(); // Verbindung erzwingen – genau einmal
} catch (\Throwable $e) {
$verbindungsfehler = $e->getMessage();
}
test('Die Test-Datenbank ist erreichbar', function () use ($verbindungsfehler) {
assertTrue($verbindungsfehler === null, 'Keine Verbindung: ' . (string)$verbindungsfehler);
});
if ($verbindungsfehler !== null) {
return; // Alles Weitere braucht die Verbindung; der Fehlschlag steht oben.
}
// Echte Runner-Bausteine per Reflection (pure Methoden, kein $this-State nötig)
$appRef = new ReflectionClass(Application::class);
$app = $appRef->newInstanceWithoutConstructor();
$split = $appRef->getMethod('splitSqlStatements'); $split->setAccessible(true);
$benign = $appRef->getMethod('isBenignMigrationError'); $benign->setAccessible(true);
$files = glob($root . '/database/migrations/*.sql') ?: [];
sort($files);
$apply = static function (array $dateien) use ($db, $split, $benign, $app): array {
$hardErrors = [];
foreach ($dateien as $file) {
$sql = (string) file_get_contents($file);
foreach ($split->invoke($app, $sql) as $stmt) {
$stmt = trim($stmt);
if ($stmt === '') {
continue;
}
try {
$db->getPdo()->exec($stmt);
} catch (\Throwable $e) {
$msg = $e->getMessage();
if (!$benign->invoke($app, $msg)) {
$hardErrors[] = basename($file) . ': ' . $msg;
}
}
}
}
return $hardErrors;
};
// Die Baseline läuft genau einmal: Der Runner überspringt jede Datei, die in
// `_migrations` steht, und der Migrations-Reset im Updater behält die Zeile
// für 001 bewusst bei. Für den Wiederholungslauf zählen deshalb nur die
// Inkremente – nur für sie gilt die Idempotenz-Zusage.
$inkremente = array_values(array_filter(
$files,
static fn(string $f): bool => basename($f) !== '001_initial_schema.sql'
));
// ── (1) Erster Lauf: keine harten Fehler ────────────────────────────────
$firstErrors = $apply($files);
test('Migrationen laufen auf frischer DB ohne harten Fehler', function () use ($firstErrors) {
assertTrue($firstErrors === [], 'Harte Fehler: ' . implode(' | ', $firstErrors));
});
// ── (2) Kein Schema-Drift nach dem Lauf ─────────────────────────────────
$drift = (new SchemaInspector($db))->inspect();
test('Nach dem Lauf keine fehlenden Tabellen', function () use ($drift) {
assertTrue($drift['missing_tables'] === [], 'Fehlende Tabellen: ' . implode(', ', $drift['missing_tables']));
});
test('Nach dem Lauf keine verkleinerten Spalten', function () use ($drift) {
$names = array_map(static fn($c) => $c['table'] . '.' . $c['column'], $drift['shrunk_columns']);
assertTrue($drift['shrunk_columns'] === [], 'Verkleinerte Spalten: ' . implode(', ', $names));
});
// ── (3) Zweiter Lauf ist idempotent ────────────────────────────────────
/** Zeilenzahl je Tabelle – Grundlage für den Vergleich über einen Lauf hinweg. */
$zeilenzahlen = static function () use ($db): array {
$stand = [];
foreach ($db->getPdo()->query('SHOW TABLES')->fetchAll(\PDO::FETCH_COLUMN) as $tabelle) {
$stand[$tabelle] = (int)$db->getPdo()
->query('SELECT COUNT(*) FROM `' . $tabelle . '`')
->fetchColumn();
}
return $stand;
};
$vorZweitemLauf = $zeilenzahlen();
// Eine bearbeitete Standardvorlage nachstellen: Der zweite Lauf darf sie
// nicht auf den Auslieferungsstand zurücksetzen.
$bearbeitet = null;
foreach (['email_templates', 'ticket_templates', 'asset_pdf_templates'] as $tabelle) {
if (!isset($vorZweitemLauf[$tabelle]) || $vorZweitemLauf[$tabelle] === 0) {
continue;
}
$spalten = $db->getPdo()->query('SHOW COLUMNS FROM `' . $tabelle . '`')->fetchAll(\PDO::FETCH_ASSOC);
foreach ($spalten as $spalte) {
if (str_contains(strtolower((string)$spalte['Type']), 'text')) {
$id = (int)$db->getPdo()->query('SELECT id FROM `' . $tabelle . '` ORDER BY id LIMIT 1')->fetchColumn();
$markierung = 'VOM BETREIBER BEARBEITET';
$db->getPdo()->exec(
'UPDATE `' . $tabelle . '` SET `' . $spalte['Field'] . '` = '
. $db->getPdo()->quote($markierung) . ' WHERE id = ' . $id
);
$bearbeitet = ['tabelle' => $tabelle, 'spalte' => $spalte['Field'], 'id' => $id, 'wert' => $markierung];
break 2;
}
}
}
$secondErrors = $apply($inkremente);
test('Zweiter Migrationslauf ist idempotent (nur benigne Fehler)', function () use ($secondErrors) {
assertTrue($secondErrors === [], 'Nicht-idempotente Statements: ' . implode(' | ', $secondErrors));
});
// Ein INSERT ohne Duplikatschutz wirft beim zweiten Lauf keinen Fehler – es
// fügt die Zeilen schlicht erneut ein. Erkennbar ist das nur am Zuwachs.
$nachZweitemLauf = $zeilenzahlen();
test('Der zweite Lauf legt keine Daten doppelt an', function () use ($vorZweitemLauf, $nachZweitemLauf) {
$gewachsen = [];
foreach ($nachZweitemLauf as $tabelle => $anzahl) {
$vorher = $vorZweitemLauf[$tabelle] ?? 0;
if ($anzahl > $vorher) {
$gewachsen[] = $tabelle . ': ' . $vorher . ' → ' . $anzahl;
}
}
assertSame([], $gewachsen, 'Tabellen mit Zuwachs: ' . implode(', ', $gewachsen));
});
test('Der zweite Lauf überschreibt keine bearbeiteten Inhalte', function () use ($db, $bearbeitet) {
if ($bearbeitet === null) {
echo " (keine geeignete Vorlagentabelle gefunden – Prüfung entfällt)\n";
assertTrue(true);
return;
}
$jetzt = $db->getPdo()->query(
'SELECT `' . $bearbeitet['spalte'] . '` FROM `' . $bearbeitet['tabelle'] . '`'
. ' WHERE id = ' . (int)$bearbeitet['id']
)->fetchColumn();
assertSame(
$bearbeitet['wert'],
(string)$jetzt,
$bearbeitet['tabelle'] . '.' . $bearbeitet['spalte'] . ' wurde zurückgesetzt'
);
});
};