OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

AssetHistoryTest.php

16 KB
<?php

declare(strict_types=1);

use OpenNIT\Assets\AssetHistory;

/**
 * Deckt den Lebenslauf eines Geräts ab: dass alle Quellen in einer Reihe
 * landen, dass Werte lesbar herauskommen und dass kein Rohwert der Datenbank
 * in der Oberfläche auftaucht.
 */
/** Alle Font-Awesome-Klassen, die der Lebenslauf verwendet. */
function symbole(): array
{
    $wurzel  = dirname(__DIR__);
    $quellen = (string)file_get_contents($wurzel . '/src/Assets/AssetHistory.php');
    // Das Symbol des Leerzustands steht in der Vorlage, nicht in der Klasse.
    $vorlage = (string)file_get_contents($wurzel . '/templates/assets/show.php');
    $von     = strpos($vorlage, 'id="tab-changes"');
    $bis     = strpos($vorlage, 'close tab-changes pane', (int)$von);
    $quellen .= substr($vorlage, (int)$von, (int)$bis - (int)$von);

    preg_match_all("/\bfa-[a-z0-9-]+/", $quellen, $treffer);
    $symbole = array_values(array_unique($treffer[0]));

    if (count($symbole) < 6) {
        throw new RuntimeException('Symbole des Lebenslaufs nicht gefunden');
    }
    return $symbole;
}

return static function (): void {

    /** Ein Audit-Eintrag, wie ihn die Detailseite lädt. */
    $audit = static function (string $ts, string $aktion, ?array $alt = null, ?array $neu = null, ?string $details = null): array {
        return [
            'event_timestamp' => $ts,
            'action_type'     => $aktion,
            'user_name'       => 'Theo Test',
            'old_value_json'  => $alt === null ? null : json_encode($alt),
            'new_value_json'  => $neu === null ? null : json_encode($neu),
            'details_json'    => $details,
        ];
    };

    // ----------------------------------------------------------------
    // Zusammenführung
    // ----------------------------------------------------------------

    test('Alle Quellen landen in einer nach Zeit sortierten Reihe', function () use ($audit) {
        $eintraege = AssetHistory::build(
            [$audit('2026-01-02 10:00:00', 'asset_created')],
            [['created_at' => '2026-01-04 08:00:00', 'type' => 'handout', 'person_name' => 'Anna', 'created_by_name' => 'Theo']],
            [['status' => 'returned', 'borrower_name' => 'Bea', 'actual_start' => '2026-01-01 09:00:00', 'actual_end' => '2026-01-05 09:00:00']],
            [['id' => 7, 'title' => 'RAM getauscht', 'type' => 'hardware', 'status' => 'done', 'changed_at' => '2026-01-03 12:00:00']]
        );

        $zeiten = array_column($eintraege, 'ts');
        $sortiert = $zeiten;
        rsort($sortiert);
        assertSame($sortiert, $zeiten, 'Reihenfolge ist nicht absteigend');

        assertSame(
            ['loan', 'handover', 'configlog', 'audit', 'loan'],
            array_column($eintraege, 'kind')
        );
    });

    test('Die Zählung je Art passt zur Filterleiste', function () use ($audit) {
        $eintraege = AssetHistory::build(
            [$audit('2026-01-02 10:00:00', 'asset_created'), $audit('2026-01-03 10:00:00', 'asset_updated')],
            [['created_at' => '2026-01-04 08:00:00', 'type' => 'return']]
        );

        $zahlen = AssetHistory::zaehleArten($eintraege);

        assertSame(2, $zahlen['audit']);
        assertSame(1, $zahlen['handover']);
        assertSame(0, $zahlen['loan']);
        assertSame(0, $zahlen['configlog']);
        // Jede Art aus ARTEN ist gezählt – die Leiste fragt alle ab.
        assertSame(array_keys(AssetHistory::ARTEN), array_keys($zahlen));
    });

    // ----------------------------------------------------------------
    // Lesbarkeit der Werte
    // ----------------------------------------------------------------

    test('Nur tatsächlich geänderte Felder erscheinen', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            ['Name' => 'Laptop 1', 'Status' => 'Aktiv'],
            ['Name' => 'Laptop 1', 'Status' => 'Lager']
        )]);

        $aenderungen = $eintraege[0]['changes'];

        assertSame(1, count($aenderungen));
        assertSame('Status', $aenderungen[0]['field']);
        assertSame('Aktiv', $aenderungen[0]['from']);
        assertSame('Lager', $aenderungen[0]['to']);
    });

    test('Ein geleertes Feld bleibt sichtbar', function () use ($audit) {
        // Die schreibende Seite filtert leere Werte heraus; der Schlüssel fehlt
        // dann auf der neuen Seite und dürfte sonst spurlos verschwinden.
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            ['Notizen' => 'Altgerät'],
            []
        )]);

        $aenderung = $eintraege[0]['changes'][0];

        assertSame('Notizen', $aenderung['field']);
        assertSame('Altgerät', $aenderung['from']);
        assertSame(null, $aenderung['to']);
    });

    test('Ein neu gesetztes Feld ebenso', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            [],
            ['Standort' => 'Serverraum']
        )]);

        $aenderung = $eintraege[0]['changes'][0];

        assertSame(null, $aenderung['from']);
        assertSame('Serverraum', $aenderung['to']);
    });

    test('Spaltennamen aus Altbeständen bekommen eine Beschriftung', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            ['serial_number' => 'ABC'],
            ['serial_number' => 'XYZ']
        )]);

        assertSame('Seriennummer', $eintraege[0]['changes'][0]['field']);
    });

    test('Ein unbekannter Schlüssel wird lesbar gemacht statt roh gezeigt', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            ['irgendein_feld' => 'a'],
            ['irgendein_feld' => 'b']
        )]);

        assertSame('Irgendein Feld', $eintraege[0]['changes'][0]['field']);
    });

    test('Ein bereits beschrifteter Schlüssel wird nicht umgeformt', function () use ($audit) {
        // Die schreibende Seite legt deutsche Beschriftungen ab; aus
        // „Garantie bis" darf kein „Garantie Bis" werden.
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            [],
            ['Garantie bis' => '2027-04-30', 'Zugewiesen an' => 'Anna']
        )]);

        assertSame(['Garantie bis', 'Zugewiesen an'], array_column($eintraege[0]['changes'], 'field'));
    });

    test('Eine Änderung ohne aufgezeichnete Werte sagt das auch', function () use ($audit) {
        // Das Audit-Log führt nicht jedes Feld mit Werten – Zusatzfelder etwa
        // nicht. „Geändert" ohne jede Zeile ließe den Leser ratlos zurück.
        $eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', 'asset_updated', [], [])])[0];

        assertSame([], $eintrag['changes']);
        assertSame('Keine Feldänderung aufgezeichnet.', $eintrag['detail']);
    });

    test('Eine eigene Meldung hat Vorrang vor dem Ersatzsatz', function () use ($audit) {
        $eintrag = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            [],
            [],
            json_encode(['message' => 'Aus dem Import übernommen'])
        )])[0];

        assertSame('Aus dem Import übernommen', $eintrag['detail']);
    });

    test('Ereignisse ohne erwartbare Werte bekommen keinen Ersatzsatz', function () use ($audit) {
        // „Angelegt" oder „Anhang hinzugefügt" führen naturgemäß keine
        // Wertänderungen – dort wäre der Hinweis sinnlos.
        foreach (['asset_created', 'asset_attachment_added'] as $aktion) {
            $eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', $aktion)])[0];
            assertSame(null, $eintrag['detail'], "Ersatzsatz bei {$aktion}");
        }
    });

    test('Datum und Zeitstempel erscheinen in deutscher Schreibweise', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            ['Kaufdatum' => '2024-03-07'],
            ['Kaufdatum' => '2025-11-30']
        )]);

        assertSame('07.03.2024', $eintraege[0]['changes'][0]['from']);
        assertSame('30.11.2025', $eintraege[0]['changes'][0]['to']);
    });

    test('Ein zusammengesetzter Wert wird nicht als JSON ausgegeben', function () use ($audit) {
        $eintraege = AssetHistory::build([$audit(
            '2026-01-02 10:00:00',
            'asset_updated',
            [],
            ['Hardware-Daten' => ['8 GB', 'i5']]
        )]);

        assertSame('8 GB, i5', $eintraege[0]['changes'][0]['to']);
    });

    // ----------------------------------------------------------------
    // Beschriftung der Ereignisse
    // ----------------------------------------------------------------

    test('Jede Audit-Aktion hat eine deutsche Bezeichnung und ein Symbol', function () use ($audit) {
        foreach (['asset_created', 'asset_updated', 'asset_deleted', 'asset_attachment_added', 'asset_warranty_synced'] as $aktion) {
            $eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', $aktion)])[0];

            assertFalse(str_contains($eintrag['title'], '_'), "Rohwert als Titel: {$aktion}");
            assertTrue(str_starts_with($eintrag['icon'], 'fa-'), "Kein Symbol: {$aktion}");
        }
    });

    test('Eine unbekannte Aktion bricht nichts und zeigt keinen Rohwert', function () use ($audit) {
        $eintrag = AssetHistory::build([$audit('2026-01-02 10:00:00', 'asset_irgendwas_neues')])[0];

        assertSame('Asset Irgendwas Neues', $eintrag['title']);
        assertSame('fa-info-circle', $eintrag['icon']);
    });

    test('Eine Übergabe nennt Richtung, Person und Unterschrift', function () {
        $aus = AssetHistory::build([], [[
            'created_at' => '2026-01-04 08:00:00', 'type' => 'handout',
            'person_name' => 'Anna Beispiel', 'signature_obtained_at' => '2026-01-04 08:05:00',
            'created_by_name' => 'Theo Test',
        ]])[0];

        assertSame('Ausgegeben', $aus['title']);
        assertSame('Theo Test', $aus['actor']);
        assertTrue(str_contains($aus['detail'], 'an Anna Beispiel'));
        assertTrue(str_contains($aus['detail'], 'Unterschrift erhalten'));

        $ohne = AssetHistory::build([], [[
            'created_at' => '2026-01-04 08:00:00', 'type' => 'handout', 'person_name' => 'Anna',
        ]])[0];
        assertTrue(str_contains($ohne['detail'], 'ohne Unterschrift'));
    });

    // ----------------------------------------------------------------
    // Leihvorgänge
    // ----------------------------------------------------------------

    test('Ein abgeschlossener Leihvorgang ergibt Ausgabe und Rückgabe', function () {
        $eintraege = AssetHistory::build([], [], [[
            'status' => 'returned', 'borrower_name' => 'Bea',
            'actual_start' => '2026-02-01 09:00:00', 'actual_end' => '2026-02-08 17:00:00',
        ]]);

        assertSame(2, count($eintraege));
        assertSame('Aus Leihe zurück', $eintraege[0]['title']);
        assertSame('Verliehen', $eintraege[1]['title']);
    });

    test('Eine Reservierung erscheint mit ihrem geplanten Beginn', function () {
        $eintrag = AssetHistory::build([], [], [[
            'status' => 'reserved', 'borrower_name' => 'Bea', 'planned_start' => '2026-03-01',
        ]])[0];

        assertSame('Reserviert', $eintrag['title']);
        assertSame('Geplant', $eintrag['badge']['label']);
    });

    test('Ein abgesagter Leihvorgang taucht nicht auf', function () {
        $eintraege = AssetHistory::build([], [], [[
            'status' => 'cancelled', 'borrower_name' => 'Bea', 'planned_start' => '2026-03-01',
        ]]);

        assertSame(0, count($eintraege));
    });

    // ----------------------------------------------------------------
    // Änderungsprotokoll
    // ----------------------------------------------------------------

    test('Ein Protokoll-Eintrag verlinkt und trägt seinen Status', function () {
        $eintrag = AssetHistory::build([], [], [], [[
            'id' => 12, 'title' => 'BIOS aktualisiert', 'type' => 'firmware',
            'status' => 'failed', 'changed_at' => '2026-01-09 14:00:00',
        ]], ['firmware' => 'Firmware'])[0];

        assertSame('BIOS aktualisiert', $eintrag['title']);
        assertSame('/configlog/12', $eintrag['link']);
        assertSame('Fehlgeschlagen', $eintrag['badge']['label']);
        assertSame('danger', $eintrag['badge']['tone']);
        assertSame('Firmware', $eintrag['detail']);
    });

    // ----------------------------------------------------------------
    // Form der Einträge
    // ----------------------------------------------------------------

    test('Jeder Eintrag hat dieselben Felder', function () use ($audit) {
        $eintraege = AssetHistory::build(
            [$audit('2026-01-02 10:00:00', 'asset_created')],
            [['created_at' => '2026-01-04 08:00:00', 'type' => 'return']],
            [['status' => 'active', 'actual_start' => '2026-01-01 09:00:00']],
            [['id' => 1, 'title' => 'X', 'status' => 'done', 'changed_at' => '2026-01-03 12:00:00']]
        );

        $erwartet = ['ts', 'kind', 'title', 'icon', 'tone', 'actor', 'detail', 'link', 'badge', 'changes'];
        foreach ($eintraege as $eintrag) {
            assertSame($erwartet, array_keys($eintrag), 'Abweichende Felder bei ' . $eintrag['kind']);
            assertTrue(array_key_exists($eintrag['kind'], AssetHistory::ARTEN));
        }
    });

    // ----------------------------------------------------------------
    // Verdrahtung in der Oberfläche
    // ----------------------------------------------------------------

    test('Die Detailseite trägt keinen eigenen Verlauf mehr', function () {
        // Der Verlauf lag in einem Modal hinter der Uhr in der Kopfleiste;
        // er gehört jetzt allein in den Tab.
        $tpl = (string)file_get_contents(dirname(__DIR__) . '/templates/assets/show.php');

        assertFalse(str_contains($tpl, 'activity-modal'), 'Verlauf-Modal noch vorhanden');
        assertFalse(str_contains($tpl, 'openActivityModal'), 'Uhr-Schaltfläche noch vorhanden');
        assertTrue(str_contains($tpl, 'id="asset-timeline"'), 'Zeitleiste fehlt im Tab');
    });

    test('Der Lebenslauf-Tab hängt nicht am Änderungsprotokoll-Modul', function () {
        // Audit-Verlauf und Übergaben gibt es auch ohne das Modul.
        $tpl = (string)file_get_contents(dirname(__DIR__) . '/templates/assets/show.php');

        assertFalse(str_contains($tpl, "if (!empty(\$configlog_enabled)) \$tabs[] = 'changes';"));
        assertTrue(str_contains($tpl, "\$tabs[] = 'changes';"));
    });

    test('Die Zeitleiste kommt ohne Farbliterale aus', function () {
        // Styleguide: Farben ausschließlich über Design-Tokens.
        $css = (string)file_get_contents(dirname(__DIR__) . '/public/assets/css/app.css');
        $von = strpos($css, 'Zeitleiste (Verläufe');
        $bis = strpos($css, 'Badges', (int)$von);
        $block = substr($css, (int)$von, (int)$bis - (int)$von);

        assertTrue(str_contains($block, '.timeline-item'), 'Komponente nicht gefunden');
        assertFalse((bool)preg_match('/#[0-9a-fA-F]{3,8}\b/', $block), 'Hex-Farbe in der Zeitleiste');
    });

    test('Jedes Symbol der Zeitleiste gibt es in der mitgelieferten Schrift', function () {
        // Ein Name, den die ausgelieferte Font-Awesome-Fassung nicht kennt,
        // hinterlässt einen leeren Kreis – sichtbar erst im Browser.
        $wurzel = dirname(__DIR__);
        $css = '';
        foreach (glob($wurzel . '/public/assets/fontawesome/css/*.css') ?: [] as $datei) {
            $css .= (string)file_get_contents($datei);
        }
        assertTrue($css !== '', 'Font Awesome nicht gefunden');

        foreach (symbole() as $symbol) {
            assertTrue(str_contains($css, ".{$symbol}:before"), "Kein Zeichen in Font Awesome: {$symbol}");
        }
    });

    test('Jedes Symbol der Zeitleiste steht in der Icon-Zuordnung', function () {
        // Sonst bliebe es im Emoji-Modus monochrom.
        $map = (string)file_get_contents(dirname(__DIR__) . '/src/Core/IconMap.php');

        foreach (symbole() as $symbol) {
            assertTrue(str_contains($map, "'{$symbol}'"), "Nicht in IconMap: {$symbol}");
        }
    });
};