OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ContractHistoryTest.php

10 KB
<?php

declare(strict_types=1);

use OpenNIT\Audit\AuditLogger;
use OpenNIT\Content\ContractManager;
use OpenNIT\Core\Database;

/**
 * Deckt den Preis- und Laufzeitverlauf eines Vertrags ab.
 *
 * Die Zeitachse steht neben dem Audit-Log, nicht darin: Sie zeigt nur, was den
 * Wert und die Dauer eines Vertrags ausmacht. Daraus folgen zwei Zusagen, die
 * hier festgehalten werden – eine Umbenennung darf keinen Eintrag erzeugen,
 * und derselbe Betrag in anderer Schreibweise ebenso wenig.
 *
 * Der dritte Punkt ist die Darstellung: Gespeichert werden Rohwerte, damit ein
 * Verlauf auch nach einer geänderten Beschriftung noch stimmt. Lesbar werden
 * sie erst beim Anzeigen.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);

    /**
     * Schreibt einen Wechsel von `$alt` nach `$neu` über das private
     * recordHistory() fort und liefert die dabei entstandenen Zeilen.
     *
     * Die Datenbank ist eine Attrappe, die Einfügungen sammelt statt sie
     * auszuführen.
     */
    $verlauf = static function (array $alt, array $neu, int $userId = 7): array {
        $db = new class extends Database {
            /** @var list<array<string,mixed>> */
            public array $inserts = [];

            public function __construct()
            {
                parent::__construct([]);
            }

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

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

            public function insert(string $table, array $data): int
            {
                $this->inserts[] = ['table' => $table] + $data;
                return count($this->inserts);
            }

            public function update(string $table, array $data, string $where, array $whereParams = []): int
            {
                return 1;
            }

            public function delete(string $table, string $where, array $params = []): int
            {
                return 0;
            }
        };

        $ref = new ReflectionClass(ContractManager::class);
        $m   = $ref->newInstanceWithoutConstructor();
        foreach (['db' => $db, 'auditLogger' => new AuditLogger($db)] as $name => $wert) {
            $prop = $ref->getProperty($name);
            $prop->setAccessible(true);
            $prop->setValue($m, $wert);
        }

        $methode = new ReflectionMethod(ContractManager::class, 'recordHistory');
        $methode->setAccessible(true);
        $methode->invoke($m, 42, $alt, $neu, $userId);

        return array_values(array_filter($db->inserts, fn($r) => $r['table'] === 'contract_history'));
    };

    // ----------------------------------------------------------------
    // Was einen Eintrag erzeugt – und was nicht
    // ----------------------------------------------------------------

    test('Eine reine Umbenennung landet nicht im Verlauf', function () use ($verlauf) {
        $zeilen = $verlauf(
            ['title' => 'Alt', 'cost_amount' => '100.00', 'cost_interval' => 'monthly', 'status' => 'active'],
            ['title' => 'Neu', 'cost_amount' => '100.00', 'cost_interval' => 'monthly', 'status' => 'active']
        );

        assertSame(0, count($zeilen), 'Eine Umbenennung hat einen Eintrag erzeugt');
    });

    test('Derselbe Betrag in anderer Schreibweise gilt als unverändert', function () use ($verlauf) {
        // Aus dem Formular kommt '100', aus der Datenbank '100.00'. Ohne
        // Normalisierung wüchse der Verlauf bei jedem Speichern.
        $zeilen = $verlauf(
            ['cost_amount' => '100.00', 'tax_rate' => '19.00', 'auto_renew' => 1, 'cancellation_period_months' => '3'],
            ['cost_amount' => '100',    'tax_rate' => 19.0,    'auto_renew' => 1, 'cancellation_period_months' => 3]
        );

        assertSame(0, count($zeilen), 'Gleiche Werte wurden als Änderung gewertet');
    });

    test('Eine Preisänderung erzeugt genau einen Eintrag im Bereich Preis', function () use ($verlauf) {
        $zeilen = $verlauf(
            ['cost_amount' => '100.00', 'cost_interval' => 'monthly'],
            ['cost_amount' => '120.00', 'cost_interval' => 'yearly']
        );

        assertSame(1, count($zeilen), 'Erwartet wurde ein Eintrag');
        assertSame('price', $zeilen[0]['event_type']);
        assertSame(42, $zeilen[0]['contract_id']);
        assertSame(7, $zeilen[0]['changed_by']);

        $aenderungen = json_decode((string)$zeilen[0]['changes_json'], true);
        assertSame(2, count($aenderungen), 'Beide geänderten Felder fehlen im Eintrag');

        // Gespeichert werden Rohwerte, keine fertigen Beschriftungen.
        $felder = array_column($aenderungen, 'new', 'field');
        assertSame('120.00', $felder['cost_amount']);
        assertSame('yearly', $felder['cost_interval']);
    });

    test('Preis und Laufzeit werden getrennt festgehalten', function () use ($verlauf) {
        // Ein Speichervorgang kann beides ändern; die Zeitachse soll dann zwei
        // Einträge zeigen statt einen vermischten.
        $zeilen = $verlauf(
            ['cost_amount' => '100.00', 'end_date' => '2026-12-31', 'status' => 'active'],
            ['cost_amount' => '150.00', 'end_date' => '2027-12-31', 'status' => 'cancelled']
        );

        $bereiche = array_column($zeilen, 'event_type');
        sort($bereiche);
        assertSame(['price', 'status', 'term'], $bereiche);
    });

    test('Ein geleertes Feld wird als solches festgehalten', function () use ($verlauf) {
        $zeilen = $verlauf(['end_date' => '2026-12-31'], ['end_date' => null]);

        assertSame(1, count($zeilen));
        $aenderungen = json_decode((string)$zeilen[0]['changes_json'], true);
        assertSame('2026-12-31', $aenderungen[0]['old']);
        assertSame(null, $aenderungen[0]['new'], 'Das Leeren wurde nicht als null gespeichert');
    });

    // ----------------------------------------------------------------
    // Darstellung
    // ----------------------------------------------------------------

    test('Rohwerte werden erst beim Anzeigen lesbar', function () {
        // Der Betrag folgt der eingestellten Waehrung; ohne Angabe gilt die Vorgabe.
        assertSame("120,00\u{202F}€", ContractManager::formatHistoryValue('cost_amount', '120.00'));
        assertSame("CHF\u{202F}120.00", ContractManager::formatHistoryValue(
            'cost_amount', '120.00', new \OpenNIT\Core\Money(['currency_code' => 'CHF'])
        ));
        assertSame('19,00 %', ContractManager::formatHistoryValue('tax_rate', '19.00'));
        assertSame('jährlich', ContractManager::formatHistoryValue('cost_interval', 'yearly'));
        assertSame('Gekündigt', ContractManager::formatHistoryValue('status', 'cancelled'));
        assertSame('31.12.2026', ContractManager::formatHistoryValue('end_date', '2026-12-31'));
        assertSame('1 Monat', ContractManager::formatHistoryValue('cancellation_period_months', '1'));
        assertSame('3 Monate', ContractManager::formatHistoryValue('cancellation_period_months', '3'));
        assertSame('Ja', ContractManager::formatHistoryValue('auto_renew', '1'));
        assertSame('Nein', ContractManager::formatHistoryValue('auto_renew', '0'));
        assertSame(null, ContractManager::formatHistoryValue('end_date', null), 'Nicht gesetzt muss null bleiben');
    });

    // ----------------------------------------------------------------
    // Abgeleitete Statuswechsel bleiben draußen
    // ----------------------------------------------------------------

    test('Der tägliche Wechsel auf „Kündigung beachten" erzeugt keinen Eintrag', function () use ($wurzel) {
        // Dieser Wechsel folgt allein aus dem Datum und würde die Zeitachse
        // jedes Vertrags mit einer Zeile füllen, die nichts erklärt.
        $quelle = (string)file_get_contents($wurzel . '/src/Content/ContractManager.php');
        $start  = strpos($quelle, "update('contracts', ['status' => 'expiring']");
        assertTrue($start !== false, 'Der Status-Durchlauf fehlt');

        $umfeld = substr($quelle, max(0, $start - 600), 900);
        assertFalse(str_contains($umfeld, 'contract_history'), 'Der abgeleitete Wechsel schreibt in den Verlauf');
    });

    test('Eine automatische Verlängerung erscheint als eigener Bereich', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Content/ContractManager.php');
        $start  = strpos($quelle, 'public function autoRenewContracts(');
        assertTrue($start !== false, 'autoRenewContracts() fehlt');
        $body = substr($quelle, $start, 3500);

        assertTrue(str_contains($body, "'event_type'   => 'renewal'"), 'Die Verlängerung landet nicht im Verlauf');
        assertTrue(str_contains($body, "'changed_by'   => null"), 'Die Verlängerung wird einem Benutzer zugeschrieben');
    });

    // ----------------------------------------------------------------
    // Ablage
    // ----------------------------------------------------------------

    test('Der Verlauf verschwindet mit dem Vertrag', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/220_contract_history.sql');
        assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `contract_history`'), 'Die Tabelle fehlt');
        assertTrue(
            str_contains($sql, 'REFERENCES `contracts`(`id`) ON DELETE CASCADE'),
            'Der Verlauf bliebe nach dem Löschen des Vertrags zurück'
        );
    });

    test('Die Vertragsseite zeigt den Verlauf mit der zentralen Zeitleiste', function () use ($wurzel) {
        $tpl = (string)file_get_contents($wurzel . '/templates/contracts/show.php');
        assertTrue(str_contains($tpl, 'Preis- und Laufzeitverlauf'), 'Der Verlauf fehlt auf der Vertragsseite');

        // Die Zeitleiste steht zentral in app.css; ein eigener Nachbau unter
        // cs-Prefix würde beim nächsten Feinschliff auseinanderlaufen.
        assertTrue(str_contains($tpl, '<ul class="timeline">'), 'Die zentrale Zeitleiste wird nicht verwendet');
        assertFalse(str_contains($tpl, '.cs-timeline'), 'Die Zeitleiste wurde modulweise nachgebaut');

        $ctrl = (string)file_get_contents($wurzel . '/src/Controllers/ContractController.php');
        assertTrue(str_contains($ctrl, 'getContractTimeline('), 'Der Verlauf wird nicht an die Seite übergeben');
    });
};