OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

DeadlineActionsTest.php

12 KB
<?php

declare(strict_types=1);

/**
 * Quittieren und Verschieben einzelner Fristen.
 *
 * Fristen haben keine eigene Zeile – sie entstehen abgeleitet aus anderen
 * Modulen. Eine Entscheidung haftet deshalb am Tripel aus Quelle, Objekt und
 * Fälligkeitsdatum; verschiebt sich das Datum am Ursprung, ist es eine neue
 * Frist. Genau das prüfen diese Fälle.
 */

use OpenNIT\Core\Database;
use OpenNIT\Deadlines\DeadlineActions;

return function (): void {

    $wurzel = dirname(__DIR__);

    /** Datenbank-Doppel, das Schreibvorgänge mitschreibt. */
    $db = static function (array $zeilen = []): Database {
        return new class($zeilen) extends Database {
            /** @var list<array{sql:string, params:array}> */
            public array $schreib = [];

            public function __construct(private array $zeilen)
            {
                parent::__construct([]);
            }

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

            public function fetchOne(string $sql, array $params = []): ?array
            {
                return $this->zeilen[0] ?? null;
            }

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

    $audit = static function (): OpenNIT\Audit\AuditLogger {
        return new class extends OpenNIT\Audit\AuditLogger {
            /** @var list<string> */
            public array $eintraege = [];
            public function __construct() {}
            public function logWithUser(
                int $userId, string $actionCategory, string $actionType, ?string $objectType = null,
                ?int $objectId = null, ?string $objectName = null, ?array $details = null,
                string $result = 'success', string $riskLevel = 'low',
                ?array $oldValue = null, ?array $newValue = null
            ): int {
                $this->eintraege[] = $actionType;
                return 1;
            }
        };
    };

    // ----------------------------------------------------------------
    // Verschieben
    // ----------------------------------------------------------------

    test('Ohne Begründung wird nicht verschoben', function () use ($db, $audit) {
        $doppel = $db();
        $a = new DeadlineActions($doppel, $audit());

        $e = $a->verschieben('contract', 5, '2026-10-01', '2026-10-15', '   ', 1);
        assertFalse($e['ok'], 'Eine Verschiebung ohne Grund wird angenommen');
        assertTrue(str_contains($e['message'], 'Begründung'), 'Der Grund wird nicht benannt');
        assertSame(0, count($doppel->schreib), 'Es wurde trotz Ablehnung geschrieben');
    });

    test('Ein Datum in der Vergangenheit wird abgewiesen', function () use ($db, $audit) {
        $a = new DeadlineActions($db(), $audit());
        $e = $a->verschieben('contract', 5, '2026-10-01', date('Y-m-d', strtotime('-1 day')), 'Grund', 1);
        assertFalse($e['ok'], 'Ein vergangenes Datum wird angenommen');
        assertTrue(str_contains($e['message'], 'Vergangenheit'), 'Die Ursache wird nicht benannt');
    });

    test('Beliebig weit lässt sich eine Frist nicht schieben', function () use ($db, $audit) {
        $a = new DeadlineActions($db(), $audit());
        $zuWeit = date('Y-m-d', strtotime('+' . (DeadlineActions::MAX_POSTPONE_DAYS + 30) . ' days'));

        $e = $a->verschieben('contract', 5, '2026-10-01', $zuWeit, 'Grund', 1);
        assertFalse($e['ok'], 'Eine Frist lässt sich unbegrenzt schieben');
        assertTrue(str_contains($e['message'], (string)DeadlineActions::MAX_POSTPONE_DAYS), 'Die Grenze wird nicht genannt');
    });

    test('Eine gültige Verschiebung wird festgehalten und protokolliert', function () use ($db, $audit) {
        $doppel = $db();
        $prot   = $audit();
        $a = new DeadlineActions($doppel, $prot);

        $ziel = date('Y-m-d', strtotime('+14 days'));
        $e = $a->verschieben('contract', 5, '2026-10-01', $ziel, 'Verhandlung läuft noch', 1);

        assertTrue($e['ok'], 'Die Verschiebung wird abgelehnt: ' . $e['message']);
        assertSame(1, count($doppel->schreib), 'Es wurde nicht genau einmal geschrieben');
        assertTrue(str_contains($doppel->schreib[0]['sql'], 'ON DUPLICATE KEY UPDATE'),
            'Eine zweite Entscheidung zur selben Frist legt eine weitere Zeile an');
        assertTrue(in_array('deadline_postponed', $prot->eintraege, true), 'Die Verschiebung steht nicht im Protokoll');
    });

    // ----------------------------------------------------------------
    // Quittieren
    // ----------------------------------------------------------------

    test('Quittieren geht ohne Begründung', function () use ($db, $audit) {
        $prot = $audit();
        $a = new DeadlineActions($db(), $prot);

        $e = $a->quittieren('ssl', 3, '2026-09-30', '', 1);
        assertTrue($e['ok'], 'Das Quittieren wird abgelehnt: ' . $e['message']);
        assertTrue(in_array('deadline_acknowledged', $prot->eintraege, true), 'Das Quittieren steht nicht im Protokoll');
    });

    test('Eine unbekannte Quelle oder ein krummes Datum wird abgewiesen', function () use ($db, $audit) {
        $a = new DeadlineActions($db(), $audit());

        assertFalse($a->quittieren('erfunden', 3, '2026-09-30', '', 1)['ok'], 'Eine erfundene Quelle wird angenommen');
        assertFalse($a->quittieren('ssl', 3, '30.09.2026', '', 1)['ok'], 'Ein krummes Datum wird angenommen');
        assertFalse($a->quittieren('ssl', 0, '2026-09-30', '', 1)['ok'], 'Objekt 0 wird angenommen');
    });

    // ----------------------------------------------------------------
    // Anreichern
    // ----------------------------------------------------------------

    test('Eine verschobene Frist wird nach dem neuen Datum beurteilt', function () use ($audit) {
        $inDreiTagen = date('Y-m-d', strtotime('+3 days'));
        $doppel = new class($inDreiTagen) extends Database {
            public function __construct(private string $ziel) { parent::__construct([]); }
            public function fetchAll(string $sql, array $params = []): array
            {
                return [[
                    'source' => 'contract', 'object_id' => 5, 'due_date' => '2026-01-01',
                    'action' => 'postponed', 'postponed_to' => $this->ziel,
                    'note' => 'Grund', 'created_at' => '2026-01-01 10:00:00', 'entschieden_von' => 'A. Person',
                ]];
            }
        };

        $a = new DeadlineActions($doppel, $audit());
        $fristen = $a->anreichern([[
            'source' => 'contract', 'object_id' => 5, 'due_date' => '2026-01-01',
            'days_left' => -300, 'severity' => 'overdue',
        ]]);

        assertSame('postponed', $fristen[0]['action'], 'Die Entscheidung wird nicht angehängt');
        assertSame(3, $fristen[0]['days_left'], 'Die Restlaufzeit folgt nicht dem neuen Datum');
        assertSame('critical', $fristen[0]['severity'], 'Die Einstufung folgt nicht dem neuen Datum');
        assertSame('A. Person', $fristen[0]['action_by'], 'Wer entschieden hat, fehlt');
    });

    test('Quittiertes verschwindet nicht, sondern rückt ans Ende', function () use ($audit) {
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchAll(string $sql, array $params = []): array
            {
                return [[
                    'source' => 'ssl', 'object_id' => 1, 'due_date' => '2026-05-05',
                    'action' => 'acknowledged', 'postponed_to' => null,
                    'note' => '', 'created_at' => '2026-01-01 10:00:00', 'entschieden_von' => 'B',
                ]];
            }
        };

        $a = new DeadlineActions($doppel, $audit());
        $fristen = $a->anreichern([
            ['source' => 'ssl',      'object_id' => 1, 'due_date' => '2026-05-05', 'days_left' => 1,  'severity' => 'critical'],
            ['source' => 'contract', 'object_id' => 9, 'due_date' => '2026-06-06', 'days_left' => 40, 'severity' => 'info'],
        ]);

        assertSame(2, count($fristen), 'Eine quittierte Frist verschwindet');
        assertSame('contract', $fristen[0]['source'], 'Die offene Frist steht nicht vorn');
        assertSame('acknowledged', $fristen[1]['action'], 'Die quittierte Frist steht nicht hinten');
    });

    // ----------------------------------------------------------------
    // Anbindung
    // ----------------------------------------------------------------

    test('Ohne die Tabelle zeigt das Cockpit die Fristen trotzdem', function () use ($audit) {
        // Zwischen dem Einspielen des Updates und dem Migrationslauf liegt ein
        // Moment; die Seite darf in dieser Zeit nicht ausfallen.
        $doppel = new class extends Database {
            public function __construct() { parent::__construct([]); }
            public function fetchAll(string $sql, array $params = []): array
            {
                throw new \RuntimeException("Table 'deadline_actions' doesn't exist");
            }
        };

        $a = new DeadlineActions($doppel, $audit());
        $fristen = $a->anreichern([
            ['source' => 'ssl', 'object_id' => 1, 'due_date' => '2026-05-05', 'days_left' => 5, 'severity' => 'critical'],
        ]);

        assertSame(1, count($fristen), 'Die Frist verschwindet');
        assertSame('', $fristen[0]['action'], 'Es wird eine Entscheidung erfunden');
    });

    test('Der Fremdschlüssel passt zum Typ der Benutzer-Kennung', function () use ($wurzel) {
        // Ein abweichender Typ laesst die Migration mit errno 150 scheitern.
        $migration = (string)file_get_contents($wurzel . '/database/migrations/226_deadline_actions.sql');
        assertTrue(
            (bool)preg_match('/`created_by`\s+BIGINT UNSIGNED/', $migration),
            'created_by passt nicht zu users.id'
        );

        $baseline = (string)file_get_contents($wurzel . '/database/migrations/001_initial_schema.sql');
        $stelle = strpos($baseline, 'CREATE TABLE IF NOT EXISTS `users`');
        assertTrue($stelle !== false, 'Die Benutzertabelle ist nicht auffindbar');
        assertTrue(
            str_contains(substr($baseline, $stelle, 200), '`id` BIGINT UNSIGNED'),
            'Der Typ von users.id hat sich geaendert - der Fremdschluessel will nachgezogen werden'
        );
    });

    test('Der Aggregator liefert die Kennung des Ursprungs mit', function () use ($wurzel) {
        // Ohne sie liesse sich keine Entscheidung zuordnen.
        $agg = (string)file_get_contents($wurzel . '/src/Deadlines/DeadlineAggregator.php');
        assertTrue(str_contains($agg, "'object_id' => (int)(\$r['id'] ?? 0)"), 'Die Kennung fehlt am Eintrag');
    });

    test('Die drei Wege sind an eine Berechtigung gebunden', function () use ($wurzel) {
        $routen = (string)file_get_contents($wurzel . '/src/Core/Application.php');
        foreach (['/deadlines/acknowledge', '/deadlines/postpone', '/deadlines/revoke'] as $route) {
            $stelle = strpos($routen, "post('" . $route . "'");
            assertTrue($stelle !== false, "Die Route {$route} fehlt");
            assertTrue(
                str_contains(substr($routen, $stelle, 200), "'capability' => 'view_deadlines'"),
                "Die Route {$route} ist nicht an eine Berechtigung gebunden"
            );
        }
    });

    test('Jedes Formular der Fristenseite trägt den Token', function () use ($wurzel) {
        // Die Attributreihenfolge ist nicht einheitlich; geprüft wird jedes
        // Formular für sich, nicht die Summe.
        $seite = (string)file_get_contents($wurzel . '/templates/deadlines/index.php');
        preg_match_all('/<form\b(.*?)<\/form>/s', $seite, $treffer);
        assertTrue(count($treffer[0]) >= 4, 'Es fehlen Formulare auf der Seite');

        $geprueft = 0;
        foreach ($treffer[0] as $formular) {
            $kopf = substr($formular, 0, (int)strpos($formular, '>'));
            if (stripos($kopf, 'method="POST"') === false) {
                continue;
            }
            $geprueft++;
            assertTrue(
                str_contains($formular, 'name="_csrf"'),
                'Ein POST-Formular ohne Token: ' . substr($kopf, 0, 80)
            );
        }
        assertTrue($geprueft >= 3, 'Die Entscheidungs-Formulare fehlen');
    });
};