OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

MigrationSmokeTest.php

8 KB
<?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'
        );
    });
};