OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

TicketStateTest.php

9 KB
<?php

declare(strict_types=1);

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

/**
 * Deckt die Zustandslogik der Tickets ab.
 *
 * Zwei Zusagen hängen daran: Die Liste sortiert nach Bearbeitungsstand, und die
 * SLA-Uhr steht genau so lange, wie das Ticket in der Warteschlange liegt. Beide
 * laufen über `recordPauseTransition`, den gemeinsamen Punkt aller
 * Statuswechsel-Pfade.
 */
return static function (): void {

    /**
     * Datenbank-Attrappe, die jeden Schreibvorgang mitschreibt.
     *
     * @param string|null $status Status, den `SELECT status FROM tickets` liefert
     */
    $db = static function (?string $status = null): Database {
        return new class($status) extends Database {
            /** @var array<int,string> Ausgeführte Anweisungen in Reihenfolge */
            public array $statements = [];

            /** @var array<int,array<string,mixed>> Daten der update()-Aufrufe */
            public array $updates = [];

            public function __construct(private ?string $status = null)
            {
                parent::__construct([]);
            }

            public function insert(string $table, array $data): int
            {
                $this->statements[] = 'INSERT INTO ' . $table;
                return 42;
            }

            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->statements[] = preg_replace('/\s+/', ' ', trim($sql));
                // Der Aufrufer wertet den Rückgabewert nicht aus.
                return new class extends \PDOStatement {};
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                if ($this->status !== null && str_contains($sql, 'FROM tickets')) {
                    return ['status' => $this->status];
                }
                return null;
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                return [];
            }

            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                return false;
            }

            public function update(string $table, array $data, string $where = '', array $params = []): int
            {
                $this->statements[] = 'UPDATE ' . $table;
                $this->updates[] = $data;
                return 1;
            }
        };
    };

    /** Erzeugt den Manager ohne Konstruktor (der baut Mail-Konfiguration auf). */
    $manager = static function (Database $datenbank): TicketManager {
        $klasse = new ReflectionClass(TicketManager::class);
        $mgr    = $klasse->newInstanceWithoutConstructor();
        $feld   = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($mgr, $datenbank);
        return $mgr;
    };

    /** Enthält eine der ausgeführten Anweisungen den Ausschnitt? */
    $enthaelt = static function (array $statements, string $teil): bool {
        foreach ($statements as $s) {
            if (str_contains($s, $teil)) {
                return true;
            }
        }
        return false;
    };

    // ----------------------------------------------------------------
    // Sortierung der Liste
    // ----------------------------------------------------------------

    test('Der Bearbeitungsstand bestimmt die Reihenfolge in der Liste', function () use ($db, $manager) {
        $mgr = $manager($db());

        // Offene Vorgänge zuerst, abgeschlossene zuletzt.
        assertSame(1, $mgr->getSortOrder('new'));
        assertSame(2, $mgr->getSortOrder('open'));
        assertSame(3, $mgr->getSortOrder('waiting'));
        assertSame(4, $mgr->getSortOrder('solved'));
        assertSame(5, $mgr->getSortOrder('closed'));
    });

    test('Ein unbekannter Status wird wie „offen" einsortiert', function () use ($db, $manager) {
        // Sonst rutschte ein Ticket mit unerwartetem Status ans Listenende und
        // fiele der Bearbeitung aus dem Blick.
        assertSame($manager($db())->getSortOrder('open'), $manager($db())->getSortOrder('irgendwas'));
    });

    // ----------------------------------------------------------------
    // SLA-Uhr
    // ----------------------------------------------------------------

    test('Ein Wechsel auf denselben Status bleibt folgenlos', function () use ($db, $manager) {
        $datenbank = $db();
        $manager($datenbank)->recordPauseTransition(1, 'open', 'open');

        assertSame(0, count($datenbank->statements), 'Ein Nicht-Wechsel wurde protokolliert');
    });

    test('Der Eintritt in die Warteschlange hält die SLA-Uhr an', function () use ($db, $manager, $enthaelt) {
        $datenbank = $db();
        $manager($datenbank)->recordPauseTransition(1, 'open', 'waiting');

        assertTrue($enthaelt($datenbank->statements, 'sla_paused_at = NOW()'), 'Die Uhr läuft weiter');
        // Nur setzen, wenn noch keine Pause läuft – sonst ginge die bereits
        // angelaufene Pausenzeit beim erneuten Setzen verloren.
        assertTrue($enthaelt($datenbank->statements, 'sla_paused_at IS NULL'), 'Eine laufende Pause wird überschrieben');
    });

    test('Der Austritt schreibt die Pausenzeit fort und lässt die Uhr weiterlaufen', function () use ($db, $manager, $enthaelt) {
        $datenbank = $db();
        $manager($datenbank)->recordPauseTransition(1, 'waiting', 'open');

        assertTrue(
            $enthaelt($datenbank->statements, 'sla_paused_total_seconds + GREATEST(0,'),
            'Die Pausenzeit wird nicht aufaddiert'
        );
        assertTrue($enthaelt($datenbank->statements, 'sla_paused_at = NULL'), 'Die Pause wird nicht beendet');
    });

    test('Die Pausenzeit wächst nie um einen negativen Betrag', function () use ($db, $manager, $enthaelt) {
        // GREATEST(0, …) fängt eine rückwärts laufende Uhr ab: Eine verstellte
        // Server-Zeit darf die aufgelaufene Pausenzeit nicht verkleinern.
        $datenbank = $db();
        $manager($datenbank)->recordPauseTransition(1, 'waiting', 'solved');

        assertTrue($enthaelt($datenbank->statements, 'GREATEST(0,'), 'Kein Schutz gegen negative Differenz');
    });

    test('Ein Wechsel ohne Warteschlange rührt die SLA-Uhr nicht an', function () use ($db, $manager, $enthaelt) {
        $datenbank = $db();
        $manager($datenbank)->recordPauseTransition(1, 'open', 'solved');

        assertFalse($enthaelt($datenbank->statements, 'sla_paused_at'), 'Die SLA-Uhr wurde angefasst');
    });

    test('Jeder Wechsel wird protokolliert', function () use ($db, $manager, $enthaelt) {
        // Grundlage der Auswertung „Verweildauer je Status".
        foreach ([['open', 'waiting'], ['waiting', 'open'], ['open', 'solved'], [null, 'new']] as [$alt, $neu]) {
            $datenbank = $db();
            $manager($datenbank)->recordPauseTransition(1, $alt, $neu);
            assertTrue(
                $enthaelt($datenbank->statements, 'INSERT INTO ticket_status_log'),
                'Nicht protokolliert: ' . ($alt ?? 'null') . ' → ' . $neu
            );
        }
    });

    // ----------------------------------------------------------------
    // Wiedereröffnung durch eine Antwort
    // ----------------------------------------------------------------

    /** Setzt eine Antwort ab und liefert die Daten des Ticket-Updates. */
    $antwort = static function (Database $datenbank, TicketManager $mgr): array {
        $mgr->addWatcherReply(1, 'Antworttext', 'Anna', 'anna@example.org', true);
        return $datenbank->updates[0] ?? [];
    };

    test('Ein gelöstes oder wartendes Ticket kommt durch eine Antwort zurück', function () use ($db, $manager, $antwort) {
        foreach (['solved', 'waiting'] as $status) {
            $datenbank = $db($status);
            $daten = $antwort($datenbank, $manager($datenbank));

            assertSame('open', $daten['status'] ?? null, 'Nicht wiedereröffnet aus ' . $status);
            // Der Lösungszeitpunkt wird ausdrücklich auf NULL gesetzt – sonst
            // gälte das Ticket in den Kennzahlen weiterhin als gelöst.
            assertTrue(array_key_exists('solved_at', $daten), 'Der Lösungszeitpunkt wird nicht zurückgesetzt');
            assertSame(null, $daten['solved_at'], 'Der Lösungszeitpunkt bleibt stehen');
        }
    });

    test('Ein geschlossenes Ticket wird durch eine Antwort nicht wiedereröffnet', function () use ($db, $manager, $antwort) {
        // Ein abgeschlossener Vorgang bleibt abgeschlossen; eine späte Antwort
        // darf ihn nicht in die Bearbeitung zurückholen.
        $datenbank = $db('closed');
        $daten = $antwort($datenbank, $manager($datenbank));

        assertFalse(isset($daten['status']), 'Der Status wurde verändert');
    });

    test('Ein offenes Ticket rutscht in der Liste nach oben', function () use ($db, $manager, $antwort) {
        // Ohne aufgefrischtes updated_at bliebe das Ticket unten stehen und der
        // Hinweis auf Ungelesenes löste nicht aus.
        $datenbank = $db('open');
        $daten = $antwort($datenbank, $manager($datenbank));

        assertFalse(isset($daten['status']), 'Ein offenes Ticket wird umgestellt');
        assertTrue(isset($daten['updated_at']), 'Die Liste sortiert das Ticket nicht nach oben');
        assertSame(42, $daten['last_reply_id'] ?? null);
    });
};