OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ApiAutoTest.php

35 KB
<?php

declare(strict_types=1);

use OpenNIT\ApiAuto\ApiAutoMapper;
use OpenNIT\ApiAuto\ApiAutoRunner;
use OpenNIT\ApiAuto\ImportTargetInterface;
use OpenNIT\ApiAuto\ImportTargetRegistry;
use OpenNIT\ApiAuto\ValueTransformer;
use OpenNIT\Core\Database;

/**
 * Haelt die Zusagen des Moduls „API-Automatisierung" fest.
 *
 * Zwei Dinge tragen die Stabilitaetszusage: die Feldumwandlung, die aus fremden
 * Daten OpenNIT-Werte macht, und die Absicherung, die verhindert, dass eine
 * fremde API mehr schreibt als vorgesehen.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);
    $src    = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);

    /** Kurzform: einen Wert nach Vorschrift umwandeln. */
    $um = static fn (array $satz, array|string $spec): mixed => ValueTransformer::extract($satz, $spec);

    // ----------------------------------------------------------------
    // Datum und Zeit
    // ----------------------------------------------------------------

    test('Ein kompaktes Datum bleibt ein Datum', function () use ($um) {
        // YYYYMMDD ist ebenfalls numerisch, meint aber kein Epoch-Sekunden.
        assertSame('2026-09-15', $um(['d' => '20260915'], ['source' => 'd', 'transform' => 'date']));
        assertSame('2026-09-15', $um(['d' => 20260915], ['source' => 'd', 'transform' => 'date']));
    });

    test('Ein Epoch-Wert bleibt ein Epoch-Wert', function () use ($um) {
        // 1789456000 = 2026-09-15; als Text ist das fuer strtotime kein Datum.
        assertSame('2026-09-15', $um(['d' => 1789456000], ['source' => 'd', 'transform' => 'date']));
        assertSame('2026-09-15', $um(['d' => '1789456000'], ['source' => 'd', 'transform' => 'date']));
    });

    test('Uebliche Textformate werden erkannt', function () use ($um) {
        assertSame('2026-09-15', $um(['d' => '2026-09-15'], ['source' => 'd', 'transform' => 'date']));
        assertSame('2026-09-15 10:15:00', $um(['d' => '2026-09-15T10:15:00Z'], ['source' => 'd', 'transform' => 'datetime']));
        assertSame('2026-09-15 10:15:00', $um(['d' => '20260915T101500Z'], ['source' => 'd', 'transform' => 'datetime']));
    });

    test('Unlesbare und fehlende Datumsangaben werden nicht geraten', function () use ($um) {
        assertSame(null, $um(['d' => 'kein datum'], ['source' => 'd', 'transform' => 'date']));
        assertSame(null, $um(['d' => ''], ['source' => 'd', 'transform' => 'date']));
        assertSame(null, $um([], ['source' => 'd', 'transform' => 'date']));
    });

    // ----------------------------------------------------------------
    // Fehlende Werte
    // ----------------------------------------------------------------

    test('Ein fehlendes Feld bleibt bei jeder Umwandlung unbestimmt', function () use ($um) {
        // Sonst entstuende aus „nicht geliefert" ein echter Wert - etwa ein
        // „nein", das die fremde API nie gesagt hat.
        foreach (['bool', 'int', 'number', 'trim', 'lower', 'upper', 'date', 'datetime'] as $t) {
            assertSame(null, $um([], ['source' => 'fehlt', 'transform' => $t]), "Umwandlung {$t}");
        }
    });

    test('Ein leerer Text zaehlt bei Zahl und Datum als unbestimmt', function () use ($um) {
        foreach (['bool', 'int', 'number', 'date', 'datetime'] as $t) {
            assertSame(null, $um(['x' => ''], ['source' => 'x', 'transform' => $t]), "Umwandlung {$t}");
        }
    });

    test('Ein leerer Text bleibt bei Text-Umwandlungen ein leerer Text', function () use ($um) {
        // „Leer" ist bei einem Textfeld eine Angabe, kein fehlender Wert.
        foreach (['trim', 'lower', 'upper'] as $t) {
            assertSame('', $um(['x' => ''], ['source' => 'x', 'transform' => $t]), "Umwandlung {$t}");
        }
    });

    test('Ein geliefertes Nein bleibt ein Nein', function () use ($um) {
        assertSame(0, $um(['x' => false], ['source' => 'x', 'transform' => 'bool']));
        assertSame(0, $um(['x' => 'nein'], ['source' => 'x', 'transform' => 'bool']));
        assertSame(0, $um(['x' => '0'], ['source' => 'x', 'transform' => 'bool']));
        assertSame(1, $um(['x' => true], ['source' => 'x', 'transform' => 'bool']));
        assertSame(1, $um(['x' => 'yes'], ['source' => 'x', 'transform' => 'bool']));
        assertSame(1, $um(['x' => 'aktiv'] , ['source' => 'x', 'transform' => 'map', 'values' => ['aktiv' => 1]]));
    });

    // ----------------------------------------------------------------
    // Uebrige Umwandlungen
    // ----------------------------------------------------------------

    test('Zusammensetzen laesst Luecken aus', function () use ($um) {
        // Sonst stuenden fuehrende oder doppelte Trennzeichen im Ergebnis.
        $spec = ['transform' => 'concat', 'sources' => ['vor', 'mittel', 'nach'], 'sep' => ' '];
        assertSame('Anna Musterfrau', $um(['vor' => 'Anna', 'mittel' => '', 'nach' => 'Musterfrau'], $spec));
        assertSame('Anna', $um(['vor' => 'Anna'], $spec));
    });

    test('Zerlegen liefert den gewuenschten Teil', function () use ($um) {
        $satz = ['n' => 'Anna Maria Musterfrau'];
        assertSame('Anna', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 'first']));
        assertSame('Musterfrau', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 'last']));
        assertSame('Maria', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 1]));
        assertSame(null, $um(['n' => ''], ['source' => 'n', 'transform' => 'split']));
    });

    test('Eine Werteliste greift, sonst gilt der Ausweichwert', function () use ($um) {
        $spec = ['source' => 's', 'transform' => 'map', 'values' => ['A' => 'aktiv'], 'default' => 'unbekannt'];
        assertSame('aktiv', $um(['s' => 'A'], $spec));
        assertSame('unbekannt', $um(['s' => 'Z'], $spec));

        // Ohne Ausweichwert bleibt der fremde Wert stehen - wer das nicht will,
        // traegt einen 'default' ein.
        $ohne = ['source' => 's', 'transform' => 'map', 'values' => ['A' => 'aktiv']];
        assertSame('Z', $um(['s' => 'Z'], $ohne));
    });

    test('Ein fester Wert braucht keine Quelle', function () use ($um) {
        assertSame('Import', $um([], ['transform' => 'const', 'value' => 'Import']));
    });

    test('Verschachtelte Felder werden ueber den Punktpfad gelesen', function () use ($um) {
        $satz = ['a' => ['b' => ['c' => 'Treffer']]];
        assertSame('Treffer', $um($satz, 'a.b.c'));
        assertSame(null, $um($satz, 'a.b.weg'));
        assertSame(null, $um($satz, 'a.weg.c'));
    });

    // ----------------------------------------------------------------
    // Grenzen der Umwandlung
    // ----------------------------------------------------------------

    test('Es gibt nur die deklarierten Umwandlungen', function () use ($src) {
        // Die Liste ist die Zusage an die Oberflaeche und an den KI-Vorschlag;
        // laeuft sie auseinander, entstehen Vorschlaege, die nichts tun.
        assertSame(
            ['none', 'date', 'datetime', 'split', 'map', 'lower', 'upper',
             'trim', 'bool', 'int', 'number', 'const', 'concat'],
            ValueTransformer::TRANSFORMS
        );

        $code = $src('/src/ApiAuto/ValueTransformer.php');
        foreach (ValueTransformer::TRANSFORMS as $t) {
            if ($t === 'none') {
                continue;
            }
            assertTrue(
                str_contains($code, "case '{$t}':") || str_contains($code, "\$transform === '{$t}'"),
                "Umwandlung '{$t}' ist angekuendigt, aber nicht umgesetzt"
            );
        }
    });

    test('Eine unbekannte Umwandlung veraendert nichts', function () use ($um) {
        // Kein Ausfall und kein stiller Datenverlust, falls ein Mapping aus einer
        // aelteren Fassung eine Umwandlung nennt, die es nicht mehr gibt.
        assertSame('unveraendert', $um(['x' => 'unveraendert'], ['source' => 'x', 'transform' => 'gibtesnicht']));
    });

    test('Zugangsdaten-Umwandlungen rechnen nachvollziehbar', function () {
        assertSame(base64_encode(hash('sha256', 'geheim', true)), ValueTransformer::credential('geheim', 'sha256_base64'));
        assertSame(md5('geheim'), ValueTransformer::credential('geheim', 'md5_hex'));
        assertSame(base64_encode('geheim'), ValueTransformer::credential('geheim', 'base64'));
        assertSame(
            base64_encode(hash_hmac('sha512', 'geheim', 'salz', true)),
            ValueTransformer::credential('geheim', 'hmac_sha512_base64', 'salz')
        );
        // Unbekanntes Verfahren gibt den Klartext zurueck, statt etwas zu erfinden.
        assertSame('geheim', ValueTransformer::credential('geheim', 'gibtesnicht'));
    });

    // ----------------------------------------------------------------
    // Handkorrekturen schuetzen
    // ----------------------------------------------------------------

    /** Baut einen Runner mit vorgegebenem Bestandsdatensatz. */
    $runnerMit = static function (?array $bestand): ApiAutoRunner {
        $db = new class ($bestand) extends Database {
            public function __construct(private ?array $bestand) {}
            public function fetchOne(string $sql, array $params = []): ?array
            {
                return $this->bestand;
            }
        };
        $klasse = new ReflectionClass(ApiAutoRunner::class);
        $runner = $klasse->newInstanceWithoutConstructor();
        $feld   = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($runner, $db);
        return $runner;
    };

    /** Ruft die geschuetzte Methode auf. */
    $wahre = static function (ApiAutoRunner $runner, array $rec, array $schutz): array {
        $m = new ReflectionMethod(ApiAutoRunner::class, 'wahreHandkorrekturen');
        $m->setAccessible(true);
        return (array)$m->invoke($runner, (new ImportTargetRegistry())->get('contacts'), $rec, $schutz, 'email');
    };

    test('Geschuetzte Felder liest der Runner aus dem Mapping', function () {
        assertSame([], ApiAutoRunner::geschuetzteFelder(['field_map' => ['a' => 'x']]));
        assertSame([], ApiAutoRunner::geschuetzteFelder([]));
        assertSame(
            ['phone'],
            ApiAutoRunner::geschuetzteFelder(['field_map' => [
                'phone' => ['source' => 'p', 'write' => 'if_empty'],
                'email' => ['source' => 'e'],
                'notes' => ['source' => 'n', 'write' => 'always'],
            ]])
        );
    });

    test('Ein vorhandener Wert ueberlebt den naechsten Lauf', function () use ($runnerMit, $wahre) {
        // Der Kern: wer eine Telefonnummer von Hand korrigiert, soll sie
        // behalten. Die Ziele aktualisieren nur uebergebene Felder.
        $ergebnis = $wahre(
            $runnerMit(['email' => 'a@b.c', 'phone' => '030 999']),
            ['email' => 'a@b.c', 'phone' => 'AUS-DER-API', 'notes' => 'immer'],
            ['phone']
        );
        assertFalse(array_key_exists('phone', $ergebnis), 'Die Handkorrektur wird ueberschrieben');
        assertSame('immer', $ergebnis['notes'], 'Ungeschuetztes Feld faellt weg');
        assertSame('a@b.c', $ergebnis['email'], 'Der Schluessel faellt weg');
    });

    test('Ein leeres Feld wird befuellt', function () use ($runnerMit, $wahre) {
        foreach ([null, '', '   '] as $leer) {
            $ergebnis = $wahre(
                $runnerMit(['email' => 'a@b.c', 'phone' => $leer]),
                ['email' => 'a@b.c', 'phone' => 'AUS-DER-API'],
                ['phone']
            );
            assertSame('AUS-DER-API', $ergebnis['phone'] ?? null, 'Leeres Feld bleibt leer');
        }
    });

    test('Ein neuer Datensatz wird vollstaendig angelegt', function () use ($runnerMit, $wahre) {
        // Ohne Bestand gibt es nichts zu wahren.
        $ergebnis = $wahre(
            $runnerMit(null),
            ['email' => 'neu@b.c', 'phone' => 'AUS-DER-API'],
            ['phone']
        );
        assertSame('AUS-DER-API', $ergebnis['phone'] ?? null);
    });

    test('Ohne Schutzfelder wird nicht einmal nachgesehen', function () use ($wahre) {
        // Kein Schutz, keine zusaetzliche Abfrage je Datensatz.
        $db = new class extends Database {
            public int $abfragen = 0;
            public function __construct() {}
            public function fetchOne(string $sql, array $params = []): ?array
            {
                $this->abfragen++;
                return null;
            }
        };
        $klasse = new ReflectionClass(ApiAutoRunner::class);
        $runner = $klasse->newInstanceWithoutConstructor();
        $feld   = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($runner, $db);

        $rec = ['email' => 'a@b.c', 'phone' => 'x'];
        assertSame($rec, $wahre($runner, $rec, []));
        assertSame(0, $db->abfragen, 'Es wird ohne Anlass gelesen');
    });

    // ----------------------------------------------------------------
    // Mapping von Hand
    // ----------------------------------------------------------------

    test('Ein Mapping laesst sich ohne KI pruefen und speichern', function () use ($src) {
        // Ohne diesen Weg braucht jede neue Verbindung einen KI-Schluessel.
        $ctrl = $src('/src/Controllers/Admin/ApiAutoController.php');
        assertTrue(str_contains($ctrl, 'function saveMappingManual'), 'Keine Aktion zum Speichern');
        assertTrue(str_contains($ctrl, 'ApiAutoMapper::pruefeMapping('), 'Ungeprueft gespeichert');
        assertTrue(str_contains($ctrl, "can('create_api_auto')"), 'Keine Rechtepruefung');

        $app = $src('/src/Core/Application.php');
        assertTrue(str_contains($app, "'/admin/api-auto/{id}/mapping-manual'"), 'Route fehlt');
    });

    test('Die Whitelist gilt auch fuer ein handgeschriebenes Mapping', function () {
        $ziel = (new ImportTargetRegistry())->get('contacts');
        $roh  = ['request' => ['path' => '/p'], 'field_map' => [
            'email'  => 'mail',
            'gehalt' => 'salary',
        ]];
        $ergebnis = ApiAutoMapper::pruefeMapping($roh, $ziel);

        assertSame(['email'], array_keys($ergebnis['mapping']['field_map']), 'Fremdes Feld durchgelassen');
        // Verworfenes wird benannt, statt stillschweigend zu verschwinden.
        assertSame(1, count($ergebnis['hinweise']));
        assertTrue(str_contains($ergebnis['hinweise'][0], 'gehalt'));
    });

    test('Ein Mapping ohne gueltige Zuordnung wird abgewiesen', function () {
        $ziel = (new ImportTargetRegistry())->get('contacts');
        $geworfen = false;
        try {
            ApiAutoMapper::pruefeMapping(['field_map' => ['gibtesnicht' => 'x']], $ziel);
        } catch (\InvalidArgumentException $e) {
            $geworfen = true;
            // Die Meldung nennt die erlaubten Felder, sonst raet der Nutzer.
            assertTrue(str_contains($e->getMessage(), 'email'), 'Meldung nennt die Felder nicht');
        }
        assertTrue($geworfen, 'Leeres Mapping wurde angenommen');
    });

    test('Die Schreibregel ueberlebt die Pruefung', function () {
        $ziel = (new ImportTargetRegistry())->get('contacts');
        $ergebnis = ApiAutoMapper::pruefeMapping(['field_map' => [
            'email' => 'mail',
            'phone' => ['source' => 'tel', 'write' => 'if_empty'],
            'notes' => ['source' => 'n', 'write' => 'always'],
        ]], $ziel);

        $map = $ergebnis['mapping']['field_map'];
        assertSame('if_empty', $map['phone']['write'] ?? null, 'Der Schutz geht verloren');
        // 'always' ist der Standard und wird nicht mitgeschleppt.
        assertFalse(isset($map['notes']['write']), 'Standardwert wird gespeichert');
    });

    test('Unsinnige Angaben werden zurechtgerueckt, nicht uebernommen', function () {
        $ziel = (new ImportTargetRegistry())->get('contacts');
        $ergebnis = ApiAutoMapper::pruefeMapping([
            'request'    => ['method' => 'DELETE', 'path' => '/p'],
            'pagination' => ['type' => 'quatsch', 'size' => 9999],
            'field_map'  => ['email' => 'mail'],
            'dedup'      => ['email', 'gehalt'],
        ], $ziel);

        $m = $ergebnis['mapping'];
        assertSame('GET', $m['request']['method'], 'Import schreibt per DELETE');
        assertSame('none', $m['pagination']['type'], 'Unbekanntes Blaettern uebernommen');
        assertSame(500, $m['pagination']['size'], 'Obergrenze nicht gekappt');
        assertSame(['email'], $m['dedup'], 'Zusammenfuehren ueber ein nicht zugeordnetes Feld');
    });

    test('Ein gespeichertes Mapping verlangt eine neue Vorschau', function () use ($src) {
        // Sonst liefe eine geaenderte Zuordnung ungeprueft nach Zeitplan.
        $mgr = $src('/src/ApiAuto/ApiAutoManager.php');
        $von = strpos($mgr, 'public function saveMapping');
        assertTrue(str_contains(substr($mgr, (int)$von, 400), "'preview_ok'   => 0"), 'Vorschau wird nicht zurueckgesetzt');
    });

    // ----------------------------------------------------------------
    // Wirkungsvorschau
    // ----------------------------------------------------------------

    /** Ruft die Wirkungsanalyse gegen einen vorgegebenen Bestand auf. */
    $wirkung = static function (array $bestand, array $records, array $mapping): array {
        $db = new class ($bestand) extends Database {
            public function __construct(private array $bestand) {}
            public function fetchOne(string $sql, array $params = []): ?array
            {
                return $this->bestand[$params[0] ?? ''] ?? null;
            }
        };
        $klasse = new ReflectionClass(ApiAutoRunner::class);
        $runner = $klasse->newInstanceWithoutConstructor();
        $feld   = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($runner, $db);

        $m = new ReflectionMethod(ApiAutoRunner::class, 'wirkung');
        $m->setAccessible(true);
        return (array)$m->invoke($runner, (new ImportTargetRegistry())->get('contacts'), $records, $mapping);
    };

    test('Die Vorschau zaehlt neu, geaendert und unveraendert', function () use ($wirkung) {
        // Vor dem Scharfschalten ist nicht die geholte Zeile interessant,
        // sondern was sie im Bestand anrichtet.
        $w = $wirkung(
            [
                'a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111', 'first_name' => 'Anna'],
                'c@d.e' => ['email' => 'c@d.e', 'phone' => '030 222', 'first_name' => 'Carl'],
            ],
            [
                ['email' => 'a@b.c',   'phone' => '030 999', 'first_name' => 'Anna'],
                ['email' => 'c@d.e',   'phone' => '030 222', 'first_name' => 'Carl'],
                ['email' => 'neu@x.y', 'phone' => '1',       'first_name' => 'Neu'],
            ],
            ['field_map' => ['email' => 'e', 'phone' => 'p', 'first_name' => 'f']]
        );

        assertSame(1, $w['neu']);
        assertSame(1, $w['geaendert']);
        assertSame(1, $w['unveraendert']);
        assertSame(3, $w['geprueft']);
        assertTrue($w['vollstaendig']);
    });

    test('Die Vorschau benennt Feld, Vorher und Nachher', function () use ($wirkung) {
        $w = $wirkung(
            ['a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111']],
            [['email' => 'a@b.c', 'phone' => '030 999']],
            ['field_map' => ['email' => 'e', 'phone' => 'p']]
        );

        assertSame(1, count($w['aenderungen']));
        assertSame('a@b.c', $w['aenderungen'][0]['schluessel']);
        assertSame(
            [['feld' => 'phone', 'vorher' => '030 111', 'nachher' => '030 999']],
            $w['aenderungen'][0]['felder']
        );
    });

    test('Die Vorschau zeigt, was der Feldschutz verhindert', function () use ($wirkung) {
        // Ein geschuetztes Feld wuerde nicht geschrieben - also darf es auch
        // nicht als Aenderung angekuendigt werden.
        $bestand = ['a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111']];
        $records = [['email' => 'a@b.c', 'phone' => '030 999']];

        $ohne = $wirkung($bestand, $records, ['field_map' => ['email' => 'e', 'phone' => 'p']]);
        assertSame(1, $ohne['geaendert'], 'Ohne Schutz fehlt die Aenderung');

        $mit = $wirkung($bestand, $records, ['field_map' => [
            'email' => 'e',
            'phone' => ['source' => 'p', 'write' => 'if_empty'],
        ]]);
        assertSame(0, $mit['geaendert'], 'Geschuetztes Feld als Aenderung angekuendigt');
        assertSame(1, $mit['unveraendert']);
    });

    test('Gleiche Werte in anderer Schreibweise sind keine Aenderung', function () use ($wirkung) {
        // Die Datenbank liefert Text, die Quelle auch Zahlen und Wahrheitswerte.
        $w = $wirkung(
            ['a@b.c' => ['email' => 'a@b.c', 'phone' => '49', 'notes' => null]],
            [['email' => 'a@b.c', 'phone' => 49, 'notes' => '']],
            ['field_map' => ['email' => 'e', 'phone' => 'p', 'notes' => 'n']]
        );
        assertSame(0, $w['geaendert'], 'Schreibweise als Aenderung gewertet');
        assertSame(1, $w['unveraendert']);
    });

    test('Die Vorschau prueft nicht unbegrenzt viele Datensaetze', function () use ($wirkung) {
        // Je Datensatz eine Abfrage - bei zehntausenden waere die Vorschau
        // teurer als der Lauf.
        $records = [];
        for ($i = 0; $i < 600; $i++) {
            $records[] = ['email' => "p{$i}@x.y", 'phone' => '1'];
        }
        $w = $wirkung([], $records, ['field_map' => ['email' => 'e', 'phone' => 'p']]);

        assertSame(500, $w['geprueft']);
        assertFalse($w['vollstaendig'], 'Die Begrenzung wird nicht ausgewiesen');
    });

    test('Die Vorschau schreibt nichts', function () use ($src) {
        $code = $src('/src/ApiAuto/ApiAutoRunner.php');
        $von   = strpos($code, 'private function wirkung');
        $block = substr($code, (int)$von, 3000);

        foreach (['->update(', '->insert(', 'DELETE', 'INSERT', 'UPDATE '] as $wort) {
            assertFalse(str_contains($block, $wort), "Schreibender Zugriff in der Vorschau: {$wort}");
        }
    });

    // ----------------------------------------------------------------
    // Loeschbehandlung
    // ----------------------------------------------------------------

    test('Die Loeschbehandlung wird nur angeboten, wo sie wirkt', function () use ($src) {
        // Kontakte, Firmen und SSL-Eintraege fuehren keinen Status, ueber den
        // sich "nicht mehr vorhanden" abbilden liesse.
        $registry = new ImportTargetRegistry();
        foreach (['contacts', 'companies', 'ssl'] as $key) {
            $ziel = $registry->get($key);
            if ($ziel === null) {
                continue;
            }
            assertFalse(
                $ziel instanceof \OpenNIT\ApiAuto\SupportsDeactivation,
                "{$key} behauptet, deaktivieren zu koennen"
            );
        }

        // Der Schalter steht nicht ungeprueft im Formular …
        $tpl = $src('/templates/api-auto/show.php');
        assertTrue(str_contains($tpl, '$kannDeaktivieren'), 'Der Schalter ist ungegated');
        assertTrue(str_contains($tpl, 'SupportsDeactivation'), 'Es wird nicht auf die Faehigkeit geprueft');

        // … und wird auch serverseitig nicht gespeichert.
        $ctrl = $src('/src/Controllers/Admin/ApiAutoController.php');
        assertTrue(
            str_contains($ctrl, 'instanceof \OpenNIT\ApiAuto\SupportsDeactivation'),
            'Der Schalter laesst sich am Formular vorbei setzen'
        );
    });

    test('Wer deaktivieren kann, tut es nur bei eigenen Importen', function () use ($src) {
        // Sonst raeumte ein Import von Hand gepflegte Datensaetze ab.
        $runner = $src('/src/ApiAuto/ApiAutoRunner.php');
        assertTrue(str_contains($runner, 'instanceof SupportsDeactivation'), 'Faehigkeit ungeprueft');
        assertTrue(str_contains($runner, 'api_auto_imported_records'), 'Kein Bezug auf eigene Importe');
    });

    // ----------------------------------------------------------------
    // Absicherung: was darf ueberhaupt geschrieben werden
    // ----------------------------------------------------------------

    test('Die Feld-Whitelist greift beim Lauf, nicht nur beim Einrichten', function () use ($src) {
        // Sonst koennte ein veraendertes oder veraltetes Mapping Felder
        // befuellen, die das Ziel nie freigegeben hat.
        $code = $src('/src/ApiAuto/ApiAutoRunner.php');
        assertTrue(str_contains($code, '$allowed  = $target->writableFields();'), 'Whitelist wird nicht geladen');
        assertTrue(str_contains($code, 'if (!isset($allowed[$targetField])) {'), 'Whitelist wird nicht angewendet');
        assertTrue(str_contains($code, 'if (is_scalar($value) || $value === null) {'), 'Nicht-Skalare werden durchgelassen');
    });

    test('Automatisch laeuft nur, was ein Mensch freigegeben hat', function () use ($src) {
        $code = $src('/src/ApiAuto/ApiAutoManager.php');
        $von  = strpos($code, 'public function dueConnections');
        $block = substr($code, (int)$von, 600);

        foreach (["enabled = 1", "status = 'ready'", "preview_ok = 1"] as $bedingung) {
            assertTrue(str_contains($block, $bedingung), "Freigabe-Bedingung fehlt: {$bedingung}");
        }
        // Neu angelegte Verbindungen sind Entwuerfe.
        assertTrue(str_contains($code, "'status'       => 'draft'"), 'Neue Verbindung ist nicht Entwurf');
    });

    test('Im laufenden Betrieb ist keine KI beteiligt', function () use ($src) {
        // Die KI hilft beim Einrichten; ein Zeitplan-Lauf muss vorhersagbar sein.
        $handler = $src('/src/Cron/Handlers/ApiAutoDispatchHandler.php');
        assertFalse(str_contains($handler, 'ApiAutoMapper'), 'Der Zeitplan-Lauf zieht die KI hinzu');
        assertFalse(str_contains($handler, 'AIProvider'), 'Der Zeitplan-Lauf zieht die KI hinzu');

        $runner = $src('/src/ApiAuto/ApiAutoRunner.php');
        assertFalse(str_contains($runner, 'ApiAutoMapper'), 'Der Lauf selbst zieht die KI hinzu');
        assertFalse(str_contains($runner, 'AIProvider'), 'Der Lauf selbst zieht die KI hinzu');
    });

    test('Der Lauf haelt sich an die SSRF-Absicherung', function () use ($src) {
        $code = $src('/src/ApiAuto/ApiAutoRunner.php');
        assertTrue(str_contains($code, 'SsrfGuard::isLinkLocalOrMetadata'), 'Metadaten-Dienste nicht gesperrt');
        assertTrue(str_contains($code, 'SsrfGuard::isRequestUrlAllowed'), 'Allowlist wird nicht geprueft');
        // Weiterleitungen duerfen die Pruefung nicht umgehen.
        assertFalse(str_contains($code, 'CURLOPT_FOLLOWLOCATION => true'), 'Weiterleitungen laufen ungeprueft');
        assertTrue(str_contains($code, '$this->assertUrlAllowed($redirect, $allowlist);'), 'Weiterleitungsziel ungeprueft');
    });

    test('Zugangsdaten stehen in keiner Fehlermeldung', function () {
        // Bei einem Schluessel im Query-Teil wuerde eine ungekuerzte URL ihn
        // in Fehlermeldungen und Lauf-Protokolle tragen.
        $m = new ReflectionMethod(ApiAutoRunner::class, 'redactUrl');
        $m->setAccessible(true);
        $runner = (new ReflectionClass(ApiAutoRunner::class))->newInstanceWithoutConstructor();

        $gekuerzt = (string)$m->invoke($runner, 'https://nutzer:passwort@api.example.com:8443/v1/items?api_key=GEHEIM&x=1');
        assertSame('https://api.example.com:8443/v1/items', $gekuerzt);
        assertFalse(str_contains($gekuerzt, 'GEHEIM'), 'Schluessel steht in der Meldung');
        assertFalse(str_contains($gekuerzt, 'passwort'), 'Passwort steht in der Meldung');

        assertSame('[URL]', (string)$m->invoke($runner, 'kein-url-text'));
    });

    test('Das Audit-Protokoll bekommt keine Zugangsdaten', function () use ($src) {
        $code = $src('/src/ApiAuto/ApiAutoRunner.php');
        assertTrue(str_contains($code, "['sent' => \$sent, 'errors' => count(\$errors)]"), 'Audit-Inhalt veraendert');

        // Kein Audit-Aufruf reicht das Geheimnis oder die volle Verbindung durch.
        foreach (["auth_secret", "\$secret", "\$token"] as $begriff) {
            assertFalse(
                (bool)preg_match('/audit->log\w*\([^;]{0,400}' . preg_quote($begriff, '/') . '/s', $code),
                "Audit-Aufruf fuehrt {$begriff} mit"
            );
        }
    });

    // ----------------------------------------------------------------
    // Ziele
    // ----------------------------------------------------------------

    test('Jedes Ziel ist vollstaendig beschrieben', function () {
        $ziele = (new ImportTargetRegistry())->all();
        assertSame(10, count($ziele), 'Anzahl der Ziele hat sich geaendert');

        foreach ($ziele as $key => $ziel) {
            assertTrue($ziel instanceof ImportTargetInterface, "{$key}: falscher Typ");
            assertTrue($key !== '', 'Ziel ohne Schluessel');
            assertTrue($ziel->label() !== '', "{$key}: kein Anzeigename");
            assertTrue($ziel->description() !== '', "{$key}: keine Beschreibung fuer den KI-Vorschlag");
            assertTrue($ziel->tableName() !== '', "{$key}: keine Tabelle");

            $felder = $ziel->writableFields();
            assertTrue($felder !== [], "{$key}: leere Feld-Whitelist");

            // Ohne Dedup-Feld wuerde jeder Lauf Dubletten anlegen.
            $dedup = $ziel->dedupFields();
            assertTrue($dedup !== [], "{$key}: kein Feld zum Zusammenfuehren");
            foreach ($dedup as $f) {
                assertTrue(isset($felder[$f]), "{$key}: Dedup-Feld '{$f}' steht nicht in der Whitelist");
            }
            foreach ($ziel->virtualFields() as $f) {
                assertTrue(isset($felder[$f]), "{$key}: virtuelles Feld '{$f}' steht nicht in der Whitelist");
            }
        }
    });

    test('Ein Ziel erscheint nur mit seinem Modul', function () {
        $registry = new ImportTargetRegistry();
        // Ohne aktive Module bleiben nur modulfreie Ziele uebrig.
        foreach ($registry->available([]) as $key => $ziel) {
            assertSame('', $ziel->requiredModule(), "{$key}: erscheint ohne sein Modul");
        }
        // Ein unbekannter Schluessel liefert nichts, statt zu raten.
        assertSame(null, $registry->get('gibtesnicht'));
        assertSame(null, $registry->get(''));
    });

    // ----------------------------------------------------------------
    // Reifegrad
    // ----------------------------------------------------------------

    test('Die Kennzeichnung „experimentell" ist an allen drei Stellen weg', function () use ($src) {
        $stellen = [
            '/templates/admin/settings/index.php',
            '/templates/admin/manual/index.php',
            '/templates/admin/manual/pdf.php',
        ];
        foreach ($stellen as $datei) {
            $code = $src($datei);
            assertFalse(str_contains($code, 'API-Auto (experimentell)'), "Kennzeichnung bleibt in {$datei}");
            assertFalse(
                (bool)preg_match('/API-Auto\s*<span class="badge badge-warning">/', $code),
                "Abzeichen bleibt in {$datei}"
            );
        }
        // Der Opt-in-Charakter bleibt bestehen - das Modul bindet fremde Systeme an.
        assertTrue(str_contains($src('/public/index.php'), "'api_auto'    => false,"), 'Modul ist nicht mehr Opt-in');
    });

    test('Auch die Modulseiten selbst tragen keinen Vorbehalt mehr', function () use ($wurzel) {
        // Die drei Pflichtstellen sind nicht die einzigen Orte, an denen ein
        // Reifegrad stehen kann.
        $dateien = array_merge(
            glob($wurzel . '/templates/api-auto/*.php') ?: [],
            glob($wurzel . '/templates/admin/api-auto/*.php') ?: []
        );
        assertTrue($dateien !== [], 'Keine Modul-Templates gefunden');

        foreach ($dateien as $pfad) {
            $inhalt = (string)file_get_contents($pfad);
            $name   = basename($pfad);
            assertFalse((bool)preg_match('/[Ee]xperimentell/', $inhalt), "Vorbehalt in {$name}");
            assertFalse((bool)preg_match('/Vorschau-Feature/', $inhalt), "Vorbehalt in {$name}");
        }
    });

    test('Nirgends im Auslieferungsstand steht noch ein Vorbehalt', function () use ($wurzel) {
        // Der Reifegrad stand an mehr Stellen als den drei dokumentierten -
        // Modulseiten, Lizenz-Dialog, README, NOTICE. Deshalb wird hier der
        // gesamte ausgelieferte Stand geprueft statt einzelner Dateien.
        $suche = static function (string $verzeichnis, string $muster) use ($wurzel): array {
            $treffer = [];
            $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($wurzel . $verzeichnis));
            foreach ($it as $datei) {
                if (!$datei->isFile() || !preg_match($muster, $datei->getFilename())) {
                    continue;
                }
                $treffer[] = $datei->getPathname();
            }
            return $treffer;
        };

        $dateien = array_merge(
            $suche('/templates', '/\.php$/'),
            $suche('/src', '/\.php$/'),
            [$wurzel . '/README.md', $wurzel . '/NOTICE.md', $wurzel . '/ROADMAP.md', $wurzel . '/public/index.php']
        );

        $offen = [];
        foreach ($dateien as $pfad) {
            foreach (explode("\n", (string)file_get_contents($pfad)) as $nr => $zeile) {
                // Nur Zeilen, die beides zugleich nennen - „experimentelle
                // KI-Aktionen" des Chat-Moduls sind ein anderes Thema.
                if (preg_match('/api[-_ ]?auto/i', $zeile)
                    && preg_match('/experimentell|experimental|\(Beta\)|Vorschau-Feature/i', $zeile)) {
                    $offen[] = str_replace($wurzel, '', $pfad) . ':' . ($nr + 1);
                }
            }
        }
        assertSame([], $offen, 'Reifegrad-Vorbehalt noch vorhanden in: ' . implode(', ', $offen));
    });

    test('Die Vorschau bleibt als Voraussetzung benannt', function () use ($src) {
        // Sie ist keine Empfehlung, sondern die Bedingung fuer den Zeitplan -
        // das muss auf der Seite stehen, nicht nur im Code.
        $seite = $src('/templates/api-auto/index.php');
        assertTrue(str_contains($seite, 'Vorschau'), 'Die Vorschau wird nicht erwaehnt');
        assertTrue(str_contains($seite, 'Audit-Log'), 'Die Protokollierung wird nicht erwaehnt');
    });

    test('OpenNIT fuehrt keine Beta- oder Vorschau-Module mehr', function () use ($src) {
        // Die Stabilitaetszusage gilt jetzt fuer alle Module; ein neuer
        // Vorbehalt muesste bewusst wieder eingetragen werden.
        $schalter = $src('/templates/admin/settings/index.php');
        foreach (['⚠ Experimentell', '(Beta)', 'badge-warning">Beta'] as $marke) {
            assertFalse(str_contains($schalter, $marke), "Reifegrad-Marke bleibt: {$marke}");
        }
    });

    test('Die Ziel-Module sind bekannte Module', function () use ($wurzel) {
        // Ein vertippter Modul-Key wuerde das Ziel dauerhaft unsichtbar machen.
        $index  = (string)file_get_contents($wurzel . '/public/index.php');
        foreach ((new ImportTargetRegistry())->all() as $key => $ziel) {
            $mod = $ziel->requiredModule();
            if ($mod === '') {
                continue;
            }
            assertTrue(
                (bool)preg_match("/'" . preg_quote($mod, '/') . "'\s*=>/", $index),
                "{$key}: verlangt unbekanntes Modul '{$mod}'"
            );
        }
    });
};