OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

DashboardQueryCountTest.php

9 KB
<?php

declare(strict_types=1);

/**
 * Was die Startseite an Abfragen kostet.
 *
 * Das Dashboard trägt die meisten Kacheln der Anwendung, und jede holt ihre
 * eigenen Zahlen. Zwei Muster treiben die Zahl der Abfragen ohne Gegenwert:
 * mehrere Zählungen über dieselbe Tabelle, die sich nur in der Bedingung
 * unterscheiden, und Abfragen für Kacheln, die die betrachtende Person
 * ausgeblendet hat.
 *
 * Geprüft wird beides – die zusammengefassten Zählungen gegen ihr Ergebnis und
 * die Reihenfolge, in der die Sichtbarkeit feststeht.
 */

use OpenNIT\Content\LoanManager;
use OpenNIT\Content\OrderManager;
use OpenNIT\Core\Database;

return static function (): void {

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

    /**
     * Ein Manager mit gezählter Datenbank, die feste Zeilen liefert.
     *
     * @param  array<string,mixed> $zeile
     * @return array{0:object, 1:object}
     */
    $aufbau = static function (string $klasseName, array $zeile): array {
        $db = new class ($zeile) extends Database {
            public int $abfragen = 0;
            /** @var list<string> */
            public array $sql = [];

            public function __construct(private array $zeile)
            {
            }

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

            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                $this->abfragen++;
                $this->sql[] = $sql;
                return 0;
            }

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

        $klasse  = new ReflectionClass($klasseName);
        $manager = $klasse->newInstanceWithoutConstructor();
        $feld    = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($manager, $db);

        return [$manager, $db];
    };

    // ----------------------------------------------------------------
    // Zählungen: zusammenfassen, wo es die Datenbank entlastet
    //
    // Mehrere Zählungen derselben Tabelle lassen sich bündeln – aber nur, wenn
    // die gebündelte Abfrage nicht ihrerseits mehr lesen muss. Verliert sie die
    // einschränkende Bedingung und damit den Index, ist der gesparte Weg zur
    // Datenbank teurer erkauft als er einbringt.
    // ----------------------------------------------------------------

    test('Die Bestellzahlen kommen aus einem Durchgang', function () use ($aufbau) {
        [$om, $db] = $aufbau(OrderManager::class, [
            'offen' => 3, 'geliefert' => 2, 'abholbereit' => 1, 'geschlossen' => 10,
        ]);

        $zahlen = $om->getCounts();

        assertSame(1, $db->abfragen, 'Die vier Zustände wurden einzeln gezählt');
        assertSame(3,  $zahlen['open']);
        assertSame(2,  $zahlen['delivered']);
        assertSame(1,  $zahlen['abholbereit']);
        assertSame(10, $zahlen['closed']);
        assertSame(16, $zahlen['all'], 'Die Summe passt nicht zu den Teilen');
    });

    test('Fehlende Bestellzahlen ergeben null, keinen Fehler', function () use ($aufbau) {
        // Eine leere Tabelle liefert SUM(...) = NULL; daraus muss 0 werden.
        [$om] = $aufbau(OrderManager::class, [
            'offen' => null, 'geliefert' => null, 'abholbereit' => null, 'geschlossen' => null,
        ]);

        $zahlen = $om->getCounts();

        assertSame(0, $zahlen['open']);
        assertSame(0, $zahlen['all']);
    });

    test('Die Fristen der Leihgeräte behalten ihre eigene Bedingung', function () use ($aufbau) {
        // Zusammengefasst wären es zwei Abfragen weniger – aber ohne WHERE
        // müsste die ganze Tabelle gelesen werden, und die wächst mit jeder je
        // getätigten Ausleihe. Der Index über `status` ist hier mehr wert als
        // der gesparte Weg zur Datenbank.
        [$lm, $db] = $aufbau(LoanManager::class, [
            'total' => 5, 'available' => 2, 'lent' => 2, 'reserved' => 1,
            'maintenance' => 0, 'retired' => 0,
        ]);

        $lm->getSummary();

        foreach (array_slice($db->sql, 1) as $sql) {
            assertTrue(
                str_contains($sql, 'WHERE status ='),
                'Eine Fristen-Abfrage läuft ohne einschränkende Bedingung: ' . $sql
            );
        }
    });

    test('Die Zählungen unterscheiden die Fälle, statt alles zu zählen', function () use ($aufbau) {
        // Eine zusammengefasste Zählung ist nur dann richtig, wenn jede Zeile
        // ihrer Bedingung zugeordnet bleibt.
        [$om, $db] = $aufbau(OrderManager::class, ['offen' => 1]);
        $om->getCounts();
        $sql = $db->sql[0];

        foreach (['open', 'delivered', 'abholbereit', 'closed'] as $zustand) {
            assertTrue(str_contains($sql, "'" . $zustand . "'"), 'Der Zustand ' . $zustand . ' fehlt in der Abfrage');
        }
        assertSame(1, substr_count($sql, 'FROM orders'), 'Die Abfrage liest die Tabelle mehr als einmal');
    });

    // ----------------------------------------------------------------
    // Nichts holen, was nicht gezeigt wird
    // ----------------------------------------------------------------

    test('Die Sichtbarkeit steht vor der ersten Abfrage fest', function () use ($quelle) {
        // Wird sie erst danach ermittelt, kann sie keine Abfrage mehr sparen.
        $ctrl  = $quelle('src/Controllers/DashboardController.php');
        $start = strpos($ctrl, 'public function index(');
        assertTrue($start !== false, 'Die Startseite fehlt');
        $rumpf = substr($ctrl, $start);

        $sichtbarkeit = strpos($rumpf, '$hiddenSet      = [];');
        assertTrue($sichtbarkeit !== false, 'Die ausgeblendeten Kacheln werden nicht ermittelt');

        foreach (['new ServiceManager(', 'new OrderManager(', 'new LoanManager('] as $teuer) {
            $wo = strpos($rumpf, $teuer);
            assertTrue($wo !== false, 'Erwarteter Zugriff fehlt: ' . $teuer);
            assertTrue($sichtbarkeit < $wo, $teuer . ' läuft, bevor die Sichtbarkeit feststeht');
        }
    });

    test('Jede aufwendige Kachel fragt nur, wenn sie gezeigt wird', function () use ($quelle) {
        $ctrl = $quelle('src/Controllers/DashboardController.php');

        // Je Kachel die Bedingung, unter der ihre Daten geholt werden.
        $kacheln = [
            'loan_devices'      => "\$hasViewLoans && \$zeigt('loan_devices')",
            'my_todos'          => "\$hasViewTodos && \$zeigt('my_todos')",
            'my_kanban_tasks'   => "\$hasViewKanban && \$zeigt('my_kanban_tasks')",
            'unifi_vouchers'    => "\$zeigt('unifi_vouchers')",
            'monitoring'        => "!in_array('monitoring', \$hiddenSet, true)",
            'backup'            => "!in_array('backup', \$hiddenSet, true)",
            'cve_security'      => "!in_array('cve_security', \$hiddenSet, true)",
            'contracts_expiring' => "!in_array('contracts_expiring', \$hiddenSet, true)",
            'workflow_approvals' => "!in_array('workflow_approvals', \$hiddenSet, true)",
        ];

        foreach ($kacheln as $kachel => $bedingung) {
            assertTrue(
                str_contains($ctrl, $bedingung),
                'Die Kachel ' . $kachel . ' holt ihre Daten unabhängig davon, ob sie gezeigt wird'
            );
        }
    });

    test('Die Einstellung der Kacheln wird einmal gelesen', function () use ($quelle) {
        // Zweimal gelesen wäre eine Abfrage für nichts – und die zweite könnte
        // eine andere Antwort geben als die erste.
        $ctrl = $quelle('src/Controllers/DashboardController.php');

        assertSame(
            1,
            substr_count($ctrl, "\$this->getUserSetting('dashboard_widgets')"),
            'Die Kachel-Einstellung wird mehrfach gelesen'
        );
    });

    test('Die Seitenzahlen zählen Seiten, nicht Verknüpfungszeilen', function () use ($quelle) {
        // Die Zuständigkeiten werden angehängt; ohne DISTINCT zählte eine Seite
        // mit mehreren Einträgen mehrfach.
        $ctrl = $quelle('src/Controllers/DashboardController.php');
        $von  = strpos($ctrl, 'FROM pages p');
        assertTrue($von !== false, 'Die Seitenzählung fehlt');

        $abfrage = substr($ctrl, max(0, $von - 500), 800);
        assertTrue(str_contains($abfrage, 'COUNT(DISTINCT p.id)'), 'Die Gesamtzahl zählt Zeilen statt Seiten');
        assertTrue(str_contains($abfrage, 'LEFT JOIN page_responsibilities'), 'Ohne LEFT JOIN fehlten Seiten ohne Zuständigkeit');
    });
};