OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

TicketMajorIncidentTest.php

17 KB
<?php

declare(strict_types=1);

/**
 * Sammelstörung – die Hierarchie und was von ihr abhängt.
 *
 * Eine Sammelstörung sammelt Meldungen, eine Meldung sammelt keine weiteren.
 * Diese eine Ebene trägt alles Übrige: Wäre sie tiefer, könnte eine Antwort im
 * Kreis laufen, und „alle mitschließen" wüsste nicht, wo es aufhören soll.
 *
 * Geprüft wird deshalb, dass jeder Weg in eine zweite Ebene abgewiesen wird,
 * dass eine einzelne scheiternde Meldung die übrigen nicht aufhält und dass
 * die Aktionen nur mit Recht und im eigenen Standort erreichbar sind.
 */

use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;

return static function (): void {

    $wurzel = dirname(__DIR__);
    $quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);

    /**
     * Ein TicketManager auf einer gedachten Ticket-Tabelle.
     *
     * @param array<int,array<string,mixed>> $tickets  Kennung => Zeile
     */
    $managerMit = static function (array $tickets) {
        $db = new class ($tickets) extends Database {
            /** @var array<int,array<string,mixed>> */
            public array $tickets;
            /** @var list<array{0:string,1:array<string,mixed>,2:array<int,mixed>}> */
            public array $writes = [];

            public function __construct(array $tickets)
            {
                $this->tickets = $tickets;
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                if (str_contains($sql, 'JOIN tickets p')) {
                    $kind = $this->tickets[(int)$params[0]] ?? null;
                    $pid  = $kind['parent_ticket_id'] ?? null;
                    if ($pid === null || !isset($this->tickets[(int)$pid])) {
                        return null;
                    }
                    return ['id' => (int)$pid] + $this->tickets[(int)$pid];
                }
                return $this->tickets[(int)$params[0]] ?? null;
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                $treffer = [];
                foreach ($this->tickets as $id => $zeile) {
                    if ((int)($zeile['parent_ticket_id'] ?? 0) === (int)$params[0]) {
                        $treffer[] = ['id' => $id] + $zeile;
                    }
                }
                return $treffer;
            }

            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                $zahl = 0;
                foreach ($this->tickets as $zeile) {
                    if ((int)($zeile['parent_ticket_id'] ?? 0) === (int)$params[0]) {
                        $zahl++;
                    }
                }
                return $zahl;
            }

            public function update(string $table, array $data, string $where, array $params = []): int
            {
                $this->writes[] = [$table, $data, $params];
                if (array_key_exists('parent_ticket_id', $data) && isset($this->tickets[(int)$params[0]])) {
                    $this->tickets[(int)$params[0]]['parent_ticket_id'] = $data['parent_ticket_id'];
                }
                return 1;
            }
        };

        // Ein TicketManager, der statt zu schließen nur mitschreibt, wen er
        // angefasst hat – der Abschluss selbst hängt an Mail und SLA.
        $manager = new class extends TicketManager {
            /** @var list<array{0:int,1:string}> */
            public array $behandelt = [];

            public function __construct()
            {
            }

            public function solveTicket(int $id): void
            {
                $this->behandelt[] = [$id, 'solved'];
            }

            public function closeTicket(int $id, array $opts = []): void
            {
                $this->behandelt[] = [$id, 'closed'];
            }
        };

        // `db` ist im TicketManager privat und über die Unterklasse nicht
        // erreichbar – deshalb direkt an der Basisklasse ansetzen.
        $feld = (new ReflectionClass(TicketManager::class))->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($manager, $db);

        return [$manager, $db];
    };

    // ----------------------------------------------------------------
    // Die Hierarchie bleibt eine Ebene tief
    // ----------------------------------------------------------------

    test('Eine Meldung hängt sich an eine Sammelstörung', function () use ($managerMit) {
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => null, 'subject' => 'Meldung',       'status' => 'new'],
        ]);

        assertSame('', $manager->attachToParent(2, 1));
        assertSame(1, $db->tickets[2]['parent_ticket_id']);
    });

    test('Ein Ticket kann nicht sich selbst untergeordnet werden', function () use ($managerMit) {
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Eins', 'status' => 'open'],
        ]);

        assertTrue($manager->attachToParent(1, 1) !== '', 'Das Selbstanhängen wurde zugelassen');
        assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
    });

    test('Eine Meldung wird nicht zur Sammelstörung einer weiteren', function () use ($managerMit) {
        // Zweite Ebene: 2 hängt schon an 1, also darf 3 nicht an 2.
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'Meldung',       'status' => 'open'],
            3 => ['parent_ticket_id' => null, 'subject' => 'Weitere',       'status' => 'new'],
        ]);

        assertTrue($manager->attachToParent(3, 2) !== '', 'Eine zweite Ebene wurde zugelassen');
        assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
    });

    test('Eine Sammelstörung hängt sich nicht selbst an eine andere', function () use ($managerMit) {
        // Andersherum: 1 sammelt bereits, darf also nicht unter 9 rutschen.
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'Meldung',       'status' => 'open'],
            9 => ['parent_ticket_id' => null, 'subject' => 'Andere',        'status' => 'open'],
        ]);

        assertTrue($manager->attachToParent(1, 9) !== '', 'Eine zweite Ebene wurde zugelassen');
        assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
    });

    test('Ein Ticket, das es nicht gibt, wird abgewiesen', function () use ($managerMit) {
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
        ]);

        assertTrue($manager->attachToParent(2, 1) !== '', 'Eine unbekannte Meldung wurde angehängt');
        assertTrue($manager->attachToParent(1, 7) !== '', 'Eine unbekannte Sammelstörung wurde akzeptiert');
        assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
    });

    test('Das Lösen setzt die Zuordnung zurück', function () use ($managerMit) {
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'Meldung',       'status' => 'open'],
        ]);

        $manager->detachFromParent(2);
        assertSame(null, $db->tickets[2]['parent_ticket_id']);
    });

    test('Jede Ablehnung nennt einen Grund', function () use ($managerMit) {
        // Der Grund landet als Meldung in der Oberfläche – ein leerer Text
        // sähe dort wie Erfolg aus.
        [$manager] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'Meldung',       'status' => 'open'],
        ]);

        foreach ([[1, 1], [3, 1], [1, 3], [9, 2]] as [$kind, $eltern]) {
            $grund = $manager->attachToParent($kind, $eltern);
            assertTrue($grund !== '' && trim($grund) === $grund && str_ends_with($grund, '.'),
                'Kein brauchbarer Grund für ' . $kind . '→' . $eltern . ': ' . var_export($grund, true));
        }
    });

    // ----------------------------------------------------------------
    // Gemeinsam schließen
    // ----------------------------------------------------------------

    test('Was schon abgeschlossen ist, bleibt unangetastet', function () use ($managerMit) {
        // Ein erneutes Schließen würde den Melder ein zweites Mal
        // benachrichtigen und den Lösungszeitpunkt überschreiben.
        [$manager] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'A', 'status' => 'open'],
            3 => ['parent_ticket_id' => 1,    'subject' => 'B', 'status' => 'closed'],
            4 => ['parent_ticket_id' => 1,    'subject' => 'C', 'status' => 'solved'],
            5 => ['parent_ticket_id' => 1,    'subject' => 'D', 'status' => 'waiting'],
            6 => ['parent_ticket_id' => 9,    'subject' => 'Fremd', 'status' => 'open'],
        ]);

        assertSame(2, $manager->closeChildren(1, 'solved'));
        assertSame([[2, 'solved'], [5, 'solved']], $manager->behandelt);
    });

    test('Der Zielstatus geht an jede Meldung durch', function () use ($managerMit) {
        [$manager] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'A', 'status' => 'new'],
            3 => ['parent_ticket_id' => 1,    'subject' => 'B', 'status' => 'open'],
        ]);

        assertSame(2, $manager->closeChildren(1, 'closed'));
        assertSame([[2, 'closed'], [3, 'closed']], $manager->behandelt);
    });

    test('Eine Sammelstörung ohne Meldungen schließt nichts mit', function () use ($managerMit) {
        [$manager] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Allein', 'status' => 'open'],
        ]);

        assertSame(0, $manager->closeChildren(1, 'solved'));
        assertSame([], $manager->behandelt);
    });

    test('Ein unbekannter Zielstatus bleibt wirkungslos', function () use ($managerMit) {
        [$manager, $db] = $managerMit([
            1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
            2 => ['parent_ticket_id' => 1,    'subject' => 'A', 'status' => 'open'],
        ]);

        assertSame(0, $manager->closeChildren(1, 'open'));
        assertSame(0, $manager->closeChildren(1, ''));
        assertSame([], $manager->behandelt, 'Ein unbekannter Status hat Meldungen angefasst');
        assertSame([], $db->writes, 'Ein unbekannter Status hat geschrieben');
    });

    test('Eine scheiternde Meldung hält die übrigen nicht auf', function () use ($quelle) {
        // Ein einzelner Fehlschlag darf den Durchlauf nicht abbrechen – sonst
        // bliebe der Rest der Sammelstörung unbehandelt zurück.
        $manager = $quelle('src/Tickets/TicketManager.php');

        $abschnitt = substr(
            $manager,
            (int)strpos($manager, 'public function replyToChildren'),
            (int)strpos($manager, 'public function addLink') - (int)strpos($manager, 'public function replyToChildren')
        );

        assertSame(2, substr_count($abschnitt, 'catch (\Throwable)'),
            'Nicht jeder Durchlauf fängt den Fehlschlag einer einzelnen Meldung ab');
    });

    // ----------------------------------------------------------------
    // Schema
    // ----------------------------------------------------------------

    test('Die Zuordnung steht als Spalte im Schema', function () use ($wurzel) {
        $migration = null;
        foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
            $inhalt = (string)file_get_contents((string)$datei);
            if (str_contains($inhalt, 'parent_ticket_id')) {
                $migration = $inhalt;
                break;
            }
        }

        assertTrue($migration !== null, 'Keine Migration legt die Spalte an');
        assertTrue(str_contains($migration, 'ADD COLUMN `parent_ticket_id`'), 'Die Spalte fehlt');
        assertTrue(str_contains($migration, 'ADD INDEX `idx_tickets_parent`'), 'Ohne Index wird je Ticket voll gelesen');
        // Verschwindet die Sammelstörung, müssen die Meldungen bestehen bleiben.
        assertTrue(str_contains($migration, 'ON DELETE SET NULL'), 'Das Löschen der Sammelstörung nähme die Meldungen mit');
        assertFalse(str_contains($migration, 'ON DELETE CASCADE'), 'Das Löschen der Sammelstörung nähme die Meldungen mit');
    });

    // ----------------------------------------------------------------
    // Erreichbarkeit
    // ----------------------------------------------------------------

    test('Die Aktionen sind nur mit Recht und bei aktivem Modul erreichbar', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');

        foreach (['parentAttach', 'parentDetach', 'childrenClose'] as $aktion) {
            assertTrue(
                (bool)preg_match(
                    "/TicketController@" . $aktion . "'[^)]*'capability'\s*=>\s*'manage_tickets'/",
                    $app
                ),
                'Die Aktion ' . $aktion . ' ist ohne Rechteprüfung erreichbar'
            );
        }

        // Die Routen müssen im Modul-Block liegen; dort steht auch die
        // bestehende Verknüpfungs-Route.
        $links = (int)strpos($app, "'/tickets/{id}/links'");
        $eltern = (int)strpos($app, "'/tickets/{id}/parent'");
        assertTrue($eltern > $links && $eltern - $links < 4000,
            'Die Sammelstörung-Routen stehen nicht beim übrigen Ticket-Modul');
    });

    test('Jede schreibende Aktion prüft den Standort', function () use ($quelle) {
        // Ohne Standortprüfung könnte ein beschränkter Bearbeiter fremde
        // Tickets an seine Sammelstörung hängen.
        $controller = $quelle('src/Controllers/TicketController.php');

        foreach ([
            'parentAttach'  => 'guardTicketAction',
            'parentDetach'  => 'guardTicketAction',
            'childrenClose' => 'guardTicketLocationRedirect',
        ] as $aktion => $wache) {
            $von = (int)strpos($controller, 'public function ' . $aktion . '(');
            assertTrue($von > 0, 'Die Aktion ' . $aktion . ' fehlt');
            $abschnitt = substr($controller, $von, 1200);
            assertTrue(str_contains($abschnitt, $wache),
                'Die Aktion ' . $aktion . ' prüft den Standort nicht');
            assertTrue(str_contains($abschnitt, "authorize('manage_tickets')"),
                'Die Aktion ' . $aktion . ' prüft die Berechtigung nicht');
        }

        // Beim Anhängen zählt zusätzlich der Standort des Ziels – sonst ließe
        // sich über die Fehlermeldung die Existenz fremder Tickets abfragen.
        $von       = (int)strpos($controller, 'public function parentAttach(');
        $abschnitt = substr($controller, $von, 1600);
        assertTrue(str_contains($abschnitt, 'ticketLocationAllowed'),
            'Der Standort der Sammelstörung wird nicht geprüft');
    });

    test('Die Antwort an alle läuft nur auf ausdrücklichen Wunsch', function () use ($quelle) {
        $controller = $quelle('src/Controllers/TicketController.php');

        assertTrue(str_contains($controller, "\$request->post('reply_to_children')"),
            'Die Antwort an alle hängt an keinem Schalter');
        assertTrue(str_contains($controller, '$this->tickets->replyToChildren('),
            'Die Antwort erreicht die angehängten Meldungen nicht');
    });

    test('Die Oberfläche bietet beide Seiten der Zuordnung an', function () use ($quelle) {
        $sidebar = $quelle('templates/tickets/_sidebar.php');

        foreach ([
            '/parent/delete'  => 'Das Lösen fehlt',
            '/children/close' => 'Das gemeinsame Schließen fehlt',
            'ticket-parent-form' => 'Das Anhängen fehlt',
        ] as $stelle => $fehler) {
            assertTrue(str_contains($sidebar, $stelle), $fehler);
        }

        // Jedes schreibende Formular braucht den Token unter dem erwarteten Namen.
        $formulare = preg_match_all('/<form method="POST" action="\/tickets\/[^"]*(?:parent|children)[^"]*"/i', $sidebar);
        $token     = substr_count($sidebar, 'name="_csrf"');
        assertTrue($formulare > 0 && $token >= $formulare,
            'Ein Formular der Sammelstörung sendet keinen Token');
    });
};