OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

PortalNotificationsTest.php

17 KB
<?php

declare(strict_types=1);

/**
 * Das persönliche Postfach im Self-Service-Portal und die Kennzahlen dazu.
 *
 * Zwei Zusagen stehen hier im Mittelpunkt. Erstens: Eine Nachricht entsteht in
 * Vorgängen, die mit dem Portal sonst nichts zu tun haben – sie darf keinen
 * davon aufhalten und nirgendwo hinführen als ins Portal. Zweitens: Eine
 * Kennzahl beruht auf Aufgezeichnetem; wo nichts aufgezeichnet ist, steht Null
 * und keine Schätzung.
 */

use OpenNIT\Core\Database;
use OpenNIT\Portal\PortalMetrics;
use OpenNIT\Portal\PortalNotifications;

return static function (): void {

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

    /**
     * Datenbank-Doppel: schreibt mit und beantwortet das Nötigste.
     *
     * `portal_enabled` beantwortet die Frage von `PortalAccess::isEnabled()`,
     * ohne die keine Nachricht abgelegt wird.
     */
    $db = static function (bool $portalAn = true, ?int $personId = 7): Database {
        return new class($portalAn, $personId) extends Database {
            /** @var list<array{tabelle:string, daten:array<string,mixed>}> */
            public array $eingefuegt = [];
            /** @var list<string> */
            public array $abfragen = [];

            public function __construct(private bool $portalAn, private ?int $personId)
            {
                parent::__construct([]);
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                $this->abfragen[] = $sql;
                if (str_contains($sql, 'directory_persons')) {
                    return $this->personId !== null ? ['id' => $this->personId] : null;
                }
                if (str_contains($sql, 'system_settings')) {
                    return ['value' => $this->portalAn ? '1' : '0'];
                }
                return null;
            }

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

            public function insert(string $table, array $data): int
            {
                $this->eingefuegt[] = ['tabelle' => $table, 'daten' => $data];
                return 1;
            }

            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->abfragen[] = $sql;
                return new class extends \PDOStatement {
                    public function rowCount(): int { return 0; }
                };
            }
        };
    };

    // ----------------------------------------------------------------
    // Ablegen
    // ----------------------------------------------------------------

    test('Eine Nachricht führt nur ins Portal', function () use ($db) {
        // Titel und Ziel stammen aus dem Vorgang, der die Nachricht auslöst.
        // Zugelassen ist deshalb ausschließlich ein Pfad unterhalb von /portal.
        foreach ([
            ['/portal/tickets/12',            '/portal/tickets/12'],
            ['/portal',                       '/portal'],
            ['/portal/hilfe/3?von=melden',    '/portal/hilfe/3?von=melden'],
            ['https://fremd.example/uebernahme', null],
            ['//fremd.example/uebernahme',    null],
            ['/admin/settings',               null],
            ['javascript:alert(1)',           null],
            ["/portal/x\nLocation: /admin",   null],
            ['',                              null],
        ] as [$eingabe, $erwartet]) {
            $doppel = $db();
            (new PortalNotifications($doppel))->fuerPerson(7, 'info', 'Titel', 'Text', $eingabe);

            assertSame(1, count($doppel->eingefuegt), 'Die Nachricht wurde nicht abgelegt');
            assertSame(
                $erwartet,
                $doppel->eingefuegt[0]['daten']['link'],
                'Das Ziel „' . str_replace("\n", '\\n', $eingabe) . '" wurde falsch behandelt'
            );
        }
    });

    test('Ohne eingeschaltetes Portal entsteht keine Nachricht', function () use ($db) {
        // Sie hätte keinen Ort, an dem sie je zu sehen wäre.
        $doppel = $db(false);
        $ok = (new PortalNotifications($doppel))->fuerPerson(7, 'ticket_reply', 'Antwort', 'Text', '/portal/tickets/1');

        assertFalse($ok, 'Die Nachricht gilt als abgelegt');
        assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
    });

    test('Eine unbekannte Art wird zu einem Hinweis, nicht zu einem Fehler', function () use ($db) {
        $doppel = $db();
        (new PortalNotifications($doppel))->fuerPerson(7, 'gibtesnicht', 'Titel');

        assertSame('info', $doppel->eingefuegt[0]['daten']['type'], 'Die Art wird ungeprüft übernommen');
    });

    test('Ohne Person und ohne Titel geschieht nichts', function () use ($db) {
        $doppel = $db();
        $p = new PortalNotifications($doppel);

        assertFalse($p->fuerPerson(0, 'info', 'Titel'), 'Eine Nachricht ohne Person wird abgelegt');
        assertFalse($p->fuerPerson(7, 'info', '   '), 'Eine Nachricht ohne Titel wird abgelegt');
        assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
    });

    test('Zu einer unbekannten Adresse entsteht keine Nachricht', function () use ($db) {
        // Anträge kennen ihre Antragstellenden nur über die Adresse; gibt es
        // dazu keine aktive Person, geht die E-Mail trotzdem hinaus.
        $doppel = $db(true, null);
        $ok = (new PortalNotifications($doppel))->fuerEmail('fremd@example.org', 'request_decided', 'Antrag');

        assertFalse($ok, 'Zu einer unbekannten Adresse wird abgelegt');
        assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
    });

    test('Ein Fehler beim Ablegen hält den Vorgang nicht auf', function () {
        // Fehlt die Tabelle, darf das weder eine Antwort auf ein Ticket noch
        // die Entscheidung über einen Antrag verhindern.
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchOne(string $sql, array $params = []): ?array { return ['value' => '1']; }
            public function insert(string $table, array $data): int
            {
                throw new \PDOException("Table 'person_notifications' doesn't exist");
            }
        };

        $ok = (new PortalNotifications($doppel))->fuerPerson(7, 'ticket_reply', 'Antwort');
        assertFalse($ok, 'Ein Fehlschlag gilt als geglückt');
    });

    // ----------------------------------------------------------------
    // Zustand ändern
    // ----------------------------------------------------------------

    test('Eine fremde Nachricht lässt sich nicht anfassen', function () use ($db) {
        // Die Person steht in der Bedingung, nicht bloß in einer Prüfung davor.
        $doppel = $db();
        $p = new PortalNotifications($doppel);
        $p->alsGelesen(7, 42);
        $p->loeschen(7, 42);

        foreach ($doppel->abfragen as $sql) {
            if (str_contains($sql, 'person_notifications') && !str_contains($sql, 'SELECT')) {
                assertTrue(str_contains($sql, 'person_id = ?'), 'Die Person fehlt in der Bedingung: ' . $sql);
            }
        }
    });

    test('Ungelesenes bleibt beim Aufräumen stehen', function () use ($quelle) {
        // Das Postfach ist der einzige Ort, an dem die Person diese Meldung
        // noch findet.
        $src   = $quelle('src/Portal/PortalNotifications.php');
        $start = (int)strpos($src, 'public function aufraeumen(');
        $body  = substr($src, $start, 900);

        assertTrue(str_contains($body, 'is_read = 1'), 'Es wird auch Ungelesenes entfernt');
        assertTrue(str_contains($body, 'read_at IS NOT NULL'), 'Eine Nachricht ohne Lesezeitpunkt wird entfernt');
    });

    // ----------------------------------------------------------------
    // Die Stellen, an denen Nachrichten entstehen
    // ----------------------------------------------------------------

    test('Jeder Anlass legt eine Nachricht ab, ohne den Vorgang zu gefährden', function () use ($quelle) {
        foreach ([
            'src/Tickets/TicketManager.php'     => ['ticket_reply', 'ticket_solved', 'ticket_closed'],
            'src/Workflows/WorkflowManager.php' => ['request_decided', 'request_question'],
            'src/Content/LoanManager.php'       => ['loan_decided'],
            'src/Content/OrderManager.php'      => ['order_ready', 'order_delivered'],
        ] as $datei => $arten) {
            $src = $quelle($datei);
            foreach ($arten as $art) {
                assertTrue(
                    str_contains($src, "'" . $art . "'"),
                    'In ' . $datei . ' fehlt der Anlass ' . $art
                );
            }

            // Der Zugriff läuft über einen eigenen Helfer, und der fängt ab.
            $start = (int)strpos($src, 'private function portalNachricht(');
            assertTrue($start > 0, 'In ' . $datei . ' fehlt der Helfer');
            $body = substr($src, $start, 1600);
            assertTrue(str_contains($body, 'try {'), 'Der Helfer in ' . $datei . ' fängt nichts ab');
            assertTrue(str_contains($body, 'catch (\\Throwable)'), 'Der Helfer in ' . $datei . ' fängt nichts ab');
        }
    });

    test('Jede Art kennt Symbol, Farbe und Bezeichnung', function () {
        // Die Vorlage liest alle drei; fehlt eines, steht im Portal ein Eintrag
        // ohne Symbol oder ohne Bezeichnung.
        foreach (PortalNotifications::TYPES as $art => $darstellung) {
            foreach (['icon', 'tone', 'label'] as $feld) {
                assertTrue(
                    isset($darstellung[$feld]) && trim((string)$darstellung[$feld]) !== '',
                    'Der Art ' . $art . ' fehlt ' . $feld
                );
            }
            assertTrue(
                str_starts_with((string)$darstellung['icon'], 'fa-'),
                'Das Symbol der Art ' . $art . ' trägt kein Font-Awesome-Präfix'
            );
            assertTrue(
                in_array($darstellung['tone'], ['info', 'success', 'warning'], true),
                'Die Farbe der Art ' . $art . ' ist im Stylesheet nicht vorgesehen'
            );
        }
    });

    test('Das Postfach hängt an der Portal-Wache, nicht an einem Recht', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');
        foreach (['notificationList', 'notificationOpen', 'notificationReadAll', 'notificationDelete'] as $aktion) {
            $zeile = strstr($app, 'PortalController@' . $aktion . "'");
            assertTrue(is_string($zeile), 'Route fehlt: ' . $aktion);
            assertTrue(
                str_contains(substr($zeile, 0, 160), "['portal' => true]"),
                'Die Route ' . $aktion . ' steht ohne Portal-Wache'
            );
        }

        // Und die spezifischen Pfade stehen vor der {id}-Route.
        $gelesen = strpos($app, '/portal/nachrichten/gelesen');
        $id      = strpos($app, '/portal/nachrichten/{id}');
        assertTrue($gelesen !== false && $id !== false && $gelesen < $id, 'Die {id}-Route steht zu früh');
    });

    // ----------------------------------------------------------------
    // Kennzahlen
    // ----------------------------------------------------------------

    test('Ohne Aufzeichnung steht Null, keine Schätzung', function () {
        // Eine Instanz, auf der die Migration fehlt, darf keine erfundenen
        // Zahlen liefern.
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchOne(string $sql, array $params = []): ?array
            {
                throw new \PDOException("Table 'portal_article_views' doesn't exist");
            }
            public function fetchAll(string $sql, array $params = []): array
            {
                throw new \PDOException("Unknown column 'source'");
            }
        };

        $m = (new PortalMetrics($doppel))->uebersicht(90);

        assertSame(0, $m['nutzung']['aktiv'], 'Es werden aktive Personen erfunden');
        assertSame(0.0, $m['nutzung']['anteil'], 'Es wird ein Anteil erfunden');
        assertSame(0, $m['herkunft']['gesamt'], 'Es werden Meldungen erfunden');
        assertSame(0.0, $m['selbsthilfe']['quote'], 'Es wird eine Quote erfunden');
        assertSame([], $m['top_artikel'], 'Es werden Artikel erfunden');
        assertSame(12, count($m['verlauf']), 'Der Verlauf hat nicht zwölf Monate');
    });

    test('Der Anteil misst Portal gegen Mail, nicht gegen alles', function () {
        // Was die IT selbst erfasst, wäre ohnehin nicht über das Portal
        // gekommen und darf den Anteil nicht kleinrechnen.
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchOne(string $sql, array $params = []): ?array { return ['n' => 0]; }
            public function fetchAll(string $sql, array $params = []): array
            {
                return str_contains($sql, 'GROUP BY quelle')
                    ? [
                        ['quelle' => 'portal',  'anzahl' => 30],
                        ['quelle' => 'mail',    'anzahl' => 10],
                        ['quelle' => 'backend', 'anzahl' => 60],
                    ]
                    : [];
            }
        };

        $m = (new PortalMetrics($doppel))->uebersicht(90);

        assertSame(100, $m['herkunft']['gesamt'], 'Nicht alle Meldungen sind gezählt');
        assertSame(75.0, $m['herkunft']['anteil_portal'], 'Der Anteil misst gegen die falsche Grundmenge');
    });

    test('Eine unbekannte Herkunft zählt weder zum Portal noch zur Mail', function () {
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchOne(string $sql, array $params = []): ?array { return ['n' => 0]; }
            public function fetchAll(string $sql, array $params = []): array
            {
                return str_contains($sql, 'GROUP BY quelle')
                    ? [['quelle' => 'unbekannt', 'anzahl' => 50], ['quelle' => 'portal', 'anzahl' => 50]]
                    : [];
            }
        };

        $m = (new PortalMetrics($doppel))->uebersicht(90);

        assertSame(50, $m['herkunft']['unbekannt'], 'Die unbekannten Meldungen fehlen');
        assertSame(100.0, $m['herkunft']['anteil_portal'], 'Unbekanntes wird der Mail zugeschlagen');
    });

    test('Die Regel hinter der Selbsthilfe-Zahl steht auf der Seite', function () use ($quelle) {
        // Eine Schätzung ohne genannte Regel ist eine Behauptung.
        $vorlage = $quelle('templates/admin/portal/metrics.php');
        assertTrue(str_contains($vorlage, 'Die Regel:'), 'Die Regel wird nicht genannt');
        assertTrue(str_contains($vorlage, 'Minuten'), 'Das Fenster wird nicht genannt');
        assertTrue(
            str_contains($vorlage, 'eher zu hoch als zu niedrig'),
            'Die Richtung der Unschärfe wird verschwiegen'
        );
        assertTrue(str_contains($vorlage, '$hilfe[\'fenster\']'), 'Das Fenster steht doppelt gepflegt in der Vorlage');
    });

    test('Ein Artikelaufruf beim Melden ist als solcher erkennbar', function () use ($quelle) {
        // Nur er lässt die Frage zu, ob die Meldung dadurch entfallen ist.
        $ctrl = $quelle('src/Controllers/PortalController.php');
        assertTrue(str_contains($ctrl, "\$request->getString('von') === 'melden'"), 'Der Kontext wird nicht gelesen');
        assertTrue(str_contains($ctrl, 'merkeArtikelaufruf('), 'Der Aufruf wird nicht festgehalten');

        $vorlage = $quelle('templates/portal/ticket_new.php');
        assertTrue(str_contains($vorlage, "'?von=melden'"), 'Der Vorschlag trägt den Zusatz nicht');

        // Und das Festhalten darf das Lesen nicht verhindern.
        $start = (int)strpos($ctrl, 'private function merkeArtikelaufruf(');
        $body  = substr($ctrl, $start, 900);
        assertTrue(str_contains($body, 'catch (\\Throwable)'), 'Ein Fehler beim Zählen bricht den Aufruf ab');
    });

    test('Die Herkunft steht am Ticket, statt erraten zu werden', function () use ($quelle) {
        $tm = $quelle('src/Tickets/TicketManager.php');
        assertTrue(str_contains($tm, "'source'               => \$quelle"), 'Die Herkunft wird nicht geschrieben');
        assertTrue(
            str_contains($tm, 'if (!isset(self::SOURCES[$quelle])) {'),
            'Eine beliebige Herkunft wird übernommen'
        );

        $portal = $quelle('src/Controllers/PortalController.php');
        assertSame(
            2,
            substr_count($portal, "'source'              => 'portal',"),
            'Nicht jeder Weg, auf dem das Portal ein Ticket anlegt, kennzeichnet sich'
        );

        // Und der Bestand wird abgeleitet statt geraten.
        $sql = $quelle('database/migrations/229_portal_metrics.sql');
        assertTrue(str_contains($sql, 'ADD COLUMN `source`'), 'Die Spalte fehlt');
        assertSame(3, substr_count($sql, 'WHERE `source` IS NULL'), 'Der Bestand wird nicht zustandsunabhängig gefüllt');
    });
};