OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

TicketTimeEntriesTest.php

13 KB
<?php

declare(strict_types=1);

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

/**
 * Deckt die Zeiterfassung eines Tickets ab.
 *
 * Erfasste Zeiten stammen aus zwei Quellen – der Stoppuhr und den an einer
 * Antwort eingetragenen Minuten. Beide bleiben in ihrer Tabelle; die Karte im
 * Ticket führt sie nur zusammen. Eine Korrektur schreibt deshalb immer in die
 * Ursprungszeile, damit Karte, Antwort und Summe denselben Wert zeigen.
 */
return static function (): void {

    /** Baut einen TicketManager auf einem Datenbank-Double. */
    $manager = static function (array $timerRows, array $replyRows, ?object &$db = null): TicketManager {
        $db = new class ($timerRows, $replyRows) extends Database {
            /** @var array<int,array{table:string,data:array,where:string,params:array}> */
            public array $updates = [];

            public function __construct(private array $timerRows, private array $replyRows)
            {
                parent::__construct([]);
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                return str_contains($sql, 'ticket_replies') ? $this->replyRows : $this->timerRows;
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                $id = (int)($params[0] ?? 0);
                $quelle = str_contains($sql, 'ticket_replies') ? $this->replyRows : $this->timerRows;
                foreach ($quelle as $zeile) {
                    if ((int)$zeile['id'] === $id) {
                        return $zeile;
                    }
                }
                return null;
            }

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

        $manager = (new ReflectionClass(TicketManager::class))->newInstanceWithoutConstructor();
        $eig = new ReflectionProperty(TicketManager::class, 'db');
        $eig->setAccessible(true);
        $eig->setValue($manager, $db);

        return $manager;
    };

    // ----------------------------------------------------------------
    // Effektive Dauer eines Stoppuhr-Eintrags
    // ----------------------------------------------------------------

    test('Ein manuell gesetzter Wert schlägt die Messung', function () {
        assertSame(45, TicketManager::minutesOfTimeEntry([
            'started_at'           => '2026-09-01 09:00:00',
            'stopped_at'           => '2026-09-01 11:00:00',
            'paused_total_seconds' => 0,
            'minutes_override'     => 45,
        ]));
    });

    test('Ohne Vorgabe zählt die Messung abzüglich der Pausen', function () {
        assertSame(90, TicketManager::minutesOfTimeEntry([
            'started_at'           => '2026-09-01 09:00:00',
            'stopped_at'           => '2026-09-01 11:00:00',
            'paused_total_seconds' => 1800,
            'minutes_override'     => null,
        ]));
    });

    test('Ein laufender Eintrag hat noch keine Dauer', function () {
        assertSame(0, TicketManager::minutesOfTimeEntry([
            'started_at'           => '2026-09-01 09:00:00',
            'stopped_at'           => null,
            'paused_total_seconds' => 0,
            'minutes_override'     => null,
        ]));
    });

    test('Längere Pausen als Laufzeit ergeben keine negative Dauer', function () {
        assertSame(0, TicketManager::minutesOfTimeEntry([
            'started_at'           => '2026-09-01 09:00:00',
            'stopped_at'           => '2026-09-01 09:30:00',
            'paused_total_seconds' => 7200,
            'minutes_override'     => null,
        ]));
    });

    // ----------------------------------------------------------------
    // Zusammenführung beider Quellen
    // ----------------------------------------------------------------

    $timer = [
        ['id' => 7, 'ticket_id' => 1, 'user_name' => 'Ada', 'started_at' => '2026-09-01 09:00:00',
         'stopped_at' => '2026-09-01 10:00:00', 'paused_total_seconds' => 0, 'minutes_override' => null, 'note' => 'Analyse'],
        ['id' => 8, 'ticket_id' => 1, 'user_name' => 'Ada', 'started_at' => '2026-09-03 08:00:00',
         'stopped_at' => null, 'paused_total_seconds' => 0, 'minutes_override' => null, 'note' => null],
    ];
    $replies = [
        ['id' => 20, 'ticket_id' => 1, 'created_at' => '2026-09-02 14:00:00', 'time_spent_minutes' => 25,
         'author_name' => 'grace@example.test', 'user_name' => 'Grace'],
    ];

    test('Beide Quellen erscheinen in einer Liste, neueste zuerst', function () use ($manager, $timer, $replies) {
        $liste = $manager($timer, $replies)->getCombinedTimeEntriesForTicket(1);

        assertSame(3, count($liste));
        assertSame(['timer', 'reply', 'timer'], array_column($liste, 'source'));
        assertSame([8, 20, 7], array_column($liste, 'id'));
    });

    test('Die Herkunft bleibt am Eintrag erkennbar', function () use ($manager, $timer, $replies) {
        $liste = $manager($timer, $replies)->getCombinedTimeEntriesForTicket(1);
        $nachId = array_column($liste, null, 'id');

        assertSame('reply', $nachId[20]['source']);
        assertSame(25, $nachId[20]['minutes']);
        assertSame('Grace', $nachId[20]['user_name']);
        // Eine Antwort trägt keine eigene Notiz – dort steht ihr Zeitpunkt.
        assertTrue($nachId[20]['note'] === null);
        assertSame('2026-09-02 14:00:00', $nachId[20]['occurred_at']);

        assertSame('Analyse', $nachId[7]['note']);
        assertSame(60, $nachId[7]['minutes']);
    });

    test('Ein laufender Timer zählt noch nicht mit', function () use ($manager, $timer, $replies) {
        $m     = $manager($timer, $replies);
        $liste = array_column($m->getCombinedTimeEntriesForTicket(1), null, 'id');

        assertTrue($liste[8]['running'], 'Der laufende Eintrag ist nicht als laufend markiert');
        assertSame(0, $liste[8]['minutes']);
        // 60 aus der Stoppuhr + 25 aus der Antwort; der laufende Timer bleibt außen vor.
        assertSame(85, $m->getTotalMinutesForTicket(1));
    });

    // ----------------------------------------------------------------
    // Korrektur eines Eintrags
    // ----------------------------------------------------------------

    test('Die Korrektur eines Stoppuhr-Eintrags setzt den manuellen Wert', function () use ($manager, $timer, $replies) {
        $db = null;
        $m  = $manager($timer, $replies, $db);

        $ergebnis = $m->updateTimeEntryMinutes('timer', 7, 30);

        assertSame(1, $ergebnis['ticket_id']);
        assertSame(60, $ergebnis['old_minutes']);
        assertSame(30, $ergebnis['new_minutes']);
        assertSame('ticket_time_entries', $db->updates[0]['table']);
        assertSame(30, $db->updates[0]['data']['minutes_override']);
    });

    test('Die Korrektur einer Antwort schreibt in die Antwort selbst', function () use ($manager, $timer, $replies) {
        // Dadurch stimmen Karte, Badge an der Antwort und Summe zwangsläufig überein.
        $db = null;
        $m  = $manager($timer, $replies, $db);

        $ergebnis = $m->updateTimeEntryMinutes('reply', 20, 40);

        assertSame(25, $ergebnis['old_minutes']);
        assertSame('ticket_replies', $db->updates[0]['table']);
        assertSame(40, $db->updates[0]['data']['time_spent_minutes']);
    });

    test('Ein leerer Wert entfernt die Erfassung an einer Antwort', function () use ($manager, $timer, $replies) {
        $db = null;
        $m  = $manager($timer, $replies, $db);

        $m->updateTimeEntryMinutes('reply', 20, null);

        assertTrue($db->updates[0]['data']['time_spent_minutes'] === null);
    });

    test('Ein laufender Timer lässt sich nicht korrigieren', function () use ($manager, $timer, $replies) {
        // Seine Dauer steht erst mit dem Stoppen fest.
        $m       = $manager($timer, $replies);
        $meldung = '';
        try {
            $m->updateTimeEntryMinutes('timer', 8, 10);
        } catch (\RuntimeException $e) {
            $meldung = $e->getMessage();
        }
        assertTrue(str_contains($meldung, 'laufender Timer'), 'Meldung: ' . $meldung);
    });

    test('Eine unbekannte Herkunft wird abgewiesen', function () use ($manager, $timer, $replies) {
        $m         = $manager($timer, $replies);
        $abgewiesen = false;
        try {
            $m->updateTimeEntryMinutes('rechnung', 7, 10);
        } catch (\RuntimeException) {
            $abgewiesen = true;
        }
        assertTrue($abgewiesen);
    });

    test('Negative und absurd hohe Werte werden begrenzt', function () use ($manager, $timer, $replies) {
        $db = null;
        $m  = $manager($timer, $replies, $db);

        $m->updateTimeEntryMinutes('timer', 7, -5);
        assertSame(0, $db->updates[0]['data']['minutes_override']);

        $m->updateTimeEntryMinutes('timer', 7, 999999);
        assertSame(100000, $db->updates[1]['data']['minutes_override']);
    });

    // ----------------------------------------------------------------
    // Oberfläche
    // ----------------------------------------------------------------

    test('Die erfassten Zeiten bleiben auch bei geschlossenen Tickets sichtbar', function () {
        // Die Karte hängt allein an der aktivierten Zeiterfassung, nicht am Status.
        $sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');

        assertTrue(
            str_contains($sidebar, "if (\\OpenNIT\\Tickets\\TicketManager::isTimeTrackingEnabled()): ?>"),
            'Die Karte ist an einen Ticket-Status gekoppelt'
        );
        // Die Beschriftung kann übersetzt sein; geprüft wird der Text, nicht seine Hülle.
        assertTrue(
            str_contains($sidebar, '<span>Zeiterfassung</span>')
                || str_contains($sidebar, "\$this->te('Zeiterfassung')"),
            'Die Karte heißt nicht Zeiterfassung'
        );
    });

    test('Das Ändern folgt dem Schalter für geschlossene Tickets', function () {
        $sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');

        assertTrue(str_contains($sidebar, 'editWhenClosedEnabled()'), 'Der Schalter wird nicht ausgewertet');
        assertTrue(str_contains($sidebar, '$zeitBearbeitbar'), 'Das Formular hängt an keiner Bedingung');
    });

    test('Die Eingabe erscheint erst auf Klick', function () {
        // Bei vielen Erfassungen soll die Liste je Zeile nur Person, Dauer und
        // den Stift zeigen; das Feld gehört hinter den Klick.
        $sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
        $css     = (string)file_get_contents(dirname(__DIR__) . '/public/assets/css/tickets.css');
        $js      = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_js_sidebar.php');

        assertTrue(str_contains($sidebar, 'class="ts-te-edit-btn"'), 'Kein Stift an der Zeile');
        assertTrue(str_contains($sidebar, 'class="ts-te-form"'), 'Das Formular trägt keine eigene Klasse');
        assertTrue(str_contains($css, '.ts-te-form { display:none;'), 'Das Formular ist nicht vorab ausgeblendet');
        assertTrue(str_contains($css, '.ts-te.is-editing .ts-te-form { display:flex; }'), 'Der Klick blendet nichts ein');
        assertTrue(str_contains($js, "querySelector('.ts-te-list')"), 'Die Liste wird nicht verdrahtet');
        assertTrue(str_contains($js, 'ts-te-cancel'), 'Kein Abbruch möglich');
    });

    test('Das Stift-Symbol ist der Emoji-Umschaltung bekannt', function () {
        // Ein Icon ohne Zuordnung bliebe im Emoji-Modus als Font Awesome stehen.
        $sidebar = (string)file_get_contents(dirname(__DIR__) . '/templates/tickets/_sidebar.php');
        $map     = (string)file_get_contents(dirname(__DIR__) . '/src/Core/IconMap.php');

        assertTrue(str_contains($sidebar, '<i class="fas fa-pen"></i>'), 'Das Icon fehlt oder trägt kein Stil-Präfix');
        assertTrue(str_contains($map, "'fa-pen'"), 'Das Icon ist nicht in der IconMap eingetragen');
    });

    test('Der Schalter ist ohne Konfiguration aus', function () {
        // Ohne Zutun bleiben geschlossene Tickets unveränderlich.
        $m = new ReflectionMethod(TicketManager::class, 'editWhenClosedEnabled');
        assertTrue($m->isStatic());

        $laden = new ReflectionMethod(TicketManager::class, 'loadRuntimeConfig');
        $laden->setAccessible(true);
        $cfg = $laden->invoke(null);

        assertFalse((bool)($cfg['tickets']['edit_when_closed'] ?? false));
    });

    test('Die Korrektur-Route steht vor keiner kollidierenden Route', function () {
        // /tickets/{id}/time/... darf nicht von der Timer-Route verdeckt werden.
        $app = (string)file_get_contents(dirname(__DIR__) . '/src/Core/Application.php');

        assertTrue(
            str_contains($app, "'/tickets/{id}/time/{source}/{entry_id}'"),
            'Die Route für die Korrektur fehlt'
        );
        assertTrue(
            strpos($app, "'/tickets/{id}/timer/stop'") < strpos($app, "'/tickets/{id}/time/{source}/{entry_id}'"),
            'Die Reihenfolge der Routen hat sich geändert'
        );
    });
};