OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

StatusSnapshotSqlTest.php

6 KB
<?php

declare(strict_types=1);

/**
 * Deckt die Fortschreibung der Statusseiten-Tageshistorie ab.
 *
 * Der Snapshot schreibt aus `status_services` in `status_service_history`.
 * Beide Tabellen führen eine Spalte `status`, weshalb in der Aktualisierung
 * jede Spalte ihre Tabelle nennen muss. Fachlich gilt: pro Tag bleibt der
 * schlechteste Status stehen, damit eine zwischenzeitlich behobene Störung in
 * der Tagesanzeige sichtbar bleibt.
 */
return static function (): void {

    /** Die Anweisung aus dem Handler. */
    $sql = static function (): string {
        $src = (string)file_get_contents(
            dirname(__DIR__) . '/src/Cron/Handlers/StatusServiceSnapshotHandler.php'
        );
        $von = strpos($src, 'INSERT INTO status_service_history');
        $bis = strpos($src, '"', $von);
        return $von !== false ? substr($src, $von, $bis - $von) : '';
    };

    /** Spaltennamen einer Tabelle aus den Migrationen. @return list<string> */
    $spalten = static function (string $tabelle): array {
        foreach (glob(dirname(__DIR__) . '/database/migrations/*.sql') ?: [] as $datei) {
            $inhalt = (string)file_get_contents($datei);
            if (preg_match(
                '/CREATE TABLE IF NOT EXISTS `' . preg_quote($tabelle, '/') . '` \((.*?)\n\) ENGINE/s',
                $inhalt,
                $m
            )) {
                preg_match_all('/^\s*`([a-z_]+)`\s+[A-Z]/mi', $m[1], $sm);
                return $sm[1];
            }
        }
        return [];
    };

    // ----------------------------------------------------------------
    // Die Mehrdeutigkeit selbst
    // ----------------------------------------------------------------

    test('Beide Tabellen führen eine Spalte status', function () use ($spalten) {
        // Das ist der Grund, warum unqualifizierte Namen nicht auflösbar sind.
        assertTrue(in_array('status', $spalten('status_services'), true), 'Quelle ohne status');
        assertTrue(in_array('status', $spalten('status_service_history'), true), 'Ziel ohne status');
        // severity gibt es nur im Ziel – deshalb ist allein status mehrdeutig.
        assertFalse(in_array('severity', $spalten('status_services'), true));
        assertTrue(in_array('severity', $spalten('status_service_history'), true));
    });

    test('Die Aktualisierung nennt zu jeder Spalte ihre Tabelle', function () use ($sql) {
        $text = $sql();
        assertTrue($text !== '', 'Die Anweisung wurde nicht gefunden');

        $odku = substr($text, (int)strpos($text, 'ON DUPLICATE KEY UPDATE'));
        // Zuweisungsziele stehen am Zeilenanfang; alles Gelesene muss
        // entweder VALUES(...) oder tabellenqualifiziert sein.
        $rest = preg_replace(
            ['/status\s*=/', '/severity\s*=/', '/VALUES\([a-z_]+\)/', '/status_service_history\.[a-z_]+/'],
            '',
            $odku
        );

        assertFalse(str_contains($rest, 'status'), 'Unqualifiziertes status in der Aktualisierung');
        assertFalse(str_contains($rest, 'severity'), 'Unqualifiziertes severity in der Aktualisierung');
    });

    test('Die Quelle ist benannt und ihre Spalten sind qualifiziert', function () use ($sql) {
        $text   = $sql();
        $select = substr($text, (int)strpos($text, 'SELECT'), (int)strpos($text, 'ON DUPLICATE') - (int)strpos($text, 'SELECT'));

        assertTrue(str_contains($select, 'FROM status_services s'), 'Die Quelle trägt keinen Namen');
        assertTrue(str_contains($select, 's.id') && str_contains($select, 's.status'), 'Spalten der Quelle ohne Präfix');
    });

    test('Die Anweisung bleibt für MariaDB lesbar', function () use ($sql) {
        // Die Alias-Schreibweise (INSERT … SELECT … AS neu) gibt es erst ab
        // MySQL 8.0.19 und nicht in MariaDB; VALUES() können beide.
        $text = $sql();

        assertTrue(str_contains($text, 'VALUES(severity)'), 'VALUES() wird nicht genutzt');
        assertFalse((bool)preg_match('/\bAS\s+new\b/i', $text), 'MySQL-eigene Alias-Schreibweise');
    });

    // ----------------------------------------------------------------
    // Die fachliche Regel
    // ----------------------------------------------------------------

    /** Bildet die Aktualisierung nach: was nach dem Schreiben im Tag steht. */
    $fortschreiben = static function (array $alt, array $neu): array {
        return [
            'status'   => $neu['severity'] >= $alt['severity'] ? $neu['status'] : $alt['status'],
            'severity' => max($alt['severity'], $neu['severity']),
        ];
    };

    test('Eine Verschlechterung setzt sich durch', function () use ($fortschreiben) {
        assertSame(
            ['status' => 'major_outage', 'severity' => 3],
            $fortschreiben(['status' => 'degraded', 'severity' => 1], ['status' => 'major_outage', 'severity' => 3])
        );
    });

    test('Eine Erholung überschreibt die Störung des Tages nicht', function () use ($fortschreiben) {
        // Kern der Historie: Der schlechteste Wert des Tages bleibt stehen.
        assertSame(
            ['status' => 'major_outage', 'severity' => 3],
            $fortschreiben(['status' => 'major_outage', 'severity' => 3], ['status' => 'operational', 'severity' => 0])
        );
    });

    test('Bei gleicher Schwere zählt der jüngere Stand', function () use ($fortschreiben) {
        assertSame(
            ['status' => 'degraded', 'severity' => 1],
            $fortschreiben(['status' => 'degraded', 'severity' => 1], ['status' => 'degraded', 'severity' => 1])
        );
    });

    test('Der Vergleich liest die bisherige Schwere, nicht die neu gesetzte', function () use ($sql) {
        // Sonst hinge das Ergebnis an der Reihenfolge der Zuweisungen.
        $odku = substr($sql(), (int)strpos($sql(), 'ON DUPLICATE KEY UPDATE'));

        assertTrue(
            strpos($odku, 'status   =') < strpos($odku, 'severity ='),
            'Die Schwere wird vor dem Status fortgeschrieben'
        );
    });
};