diff --git a/CLAUDE.md b/CLAUDE.md index 8982617..4a268b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,9 @@ Tests liegen in `tests/` und folgen dem Namensschema `NN_FunktionsnameTest.php`. Jede Testklasse erweitert `TestCase` und implementiert `run()`. ```bash -docker compose exec -u www-data web php tests/run.php # alle Tests ausführen -./scripts/run-tests.sh # Tests + automatische Issue-Erstellung bei Fehlern -./scripts/run-tests.sh 42 # Tests; bei Fehlern Kommentar in Issue #42 +docker compose exec -u www-data web php tests/run.php # alle Tests ausführen +docker compose exec -u www-data web ./scripts/run-tests.sh # Tests + automatische Issue-Erstellung bei Fehlern +docker compose exec -u www-data web ./scripts/run-tests.sh 42 # Tests; bei Fehlern Kommentar in Issue #42 ``` **Immer `-u www-data`:** ohne die Option laufen die Tests als root und legen diff --git a/Changes.md b/Changes.md index d5b10da..8c00ded 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,18 @@ --- +## 2026-08-21 – Lieferantenliste (/suppliers) lief in HTTP 500: fehlender View-Import (#198) + +- **`src/Views/suppliers/list.php`:** Die View rief `View::formatDate()` ohne `use Steuerkram\View;` auf. Views werden per `include` eingebunden und laufen dadurch im globalen Namespace – das `use` des einbindenden Controllers gilt dort nicht, der Aufruf ging also auf `\View::formatDate()` und die Seite starb mit `Class "View" not found`. Sichtbar wurde das erst mit dem ersten Lieferanten, weil die Zeile in der `foreach`-Schleife steht. Import ergänzt; alle übrigen Views hatten ihn bereits. +- **`tests/99_ViewKlassenImportTest.php`** (neu): prüft alle Views in `src/Views/` statisch – jeder unqualifizierte `Klasse::`-Aufruf muss entweder per `use` importiert sein oder eine global existierende Klasse (`DateTime`, `PDO`, …) meinen. Fängt fehlende Importe ab, bevor sie im Betrieb auffallen. +- **`tests/e2e/specs/rechnung-export.spec.js`:** klickte noch `button[onclick^="showAddLineDialog"]`; der Inline-Handler-Umbau aus #160 hat daraus `data-action="show-add-line-dialog"` gemacht – fünf E2E-Tests liefen deshalb in den Timeout. Selektor nachgezogen. + +## 2026-08-21 – run-tests.sh: Commit-Gate meldete fälschlich Erfolg bei fehlgeschlagenen Tests (#195) + +- **`scripts/run-tests.sh`:** Die Auswertung von `php tests/run.php --json` hing an `python3`, das weder im Container noch auf dem Host installiert ist – der Fallback `|| echo "0"` verwandelte jeden Parse-Fehler in „0 fehlgeschlagene Tests", das Commit-Gate griff also nie. Erfolg/Misserfolg hängt jetzt direkt am Exit-Code von `php tests/run.php --json`; Detailauswertung (`failed`, `failures`) läuft über `php -r` statt Python. Lässt sich die JSON-Ausgabe nicht auswerten, bricht das Skript mit einem Fehler ab statt fälschlich Erfolg zu melden. Der zuvor ungenutzte `EXIT_CODE` (verworfen durch ein voranstehendes `|| true`) wird jetzt tatsächlich ausgewertet. Die JSON-Payloads für die automatische Issue-Erstellung werden ebenfalls per `php -r` statt Python gebaut (Body-Text jetzt mit echten Zeilenumbrüchen statt literalem `\n`). +- **`tests/run.php` / `tests/TestCase.php`:** `--json`-Modus gab bisher zusätzlich zur JSON-Ausgabe die komplette Fortschrittsanzeige (✓/✗ je Assertion) auf STDOUT aus – dadurch war die JSON-Ausgabe nie für sich allein parsbar, unabhängig vom Python-Problem. `TestCase::setQuiet()` unterdrückt diese Ausgabe im `--json`-Modus; PHP-Warnungen/-Notices einzelner Tests gehen in diesem Modus zusätzlich per `ini_set('display_errors', 'stderr')` nach STDERR statt STDOUT. +- **`CLAUDE.md`:** Aufrufzeilen für `./scripts/run-tests.sh` auf `docker compose exec -u www-data web ./scripts/run-tests.sh` korrigiert – das Skript läuft nur im Container, auf dem Host ist kein PHP installiert. + ## 2026-08-21 – SMTP-SSRF-Schutz: IP-Pinning gegen DNS-Rebinding, Admin-Pflicht für SMTP-Einstellungen (#193) - **`src/Services/SsrfGuard.php`:** neue `resolvePinnedHost(string $host): string[]` – wie `assertPublicHost()`, gibt aber zusätzlich die geprüfte(n) IP-Adresse(n) zurück, analog zu `resolvePinned()` für https-URLs. `assertPublicHost()` ruft sie jetzt intern auf. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 5bc2120..b2e3a1b 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -3,8 +3,8 @@ # ============================================================ # Steuerkram Test-Runner mit automatischer Issue-Erstellung # -# Verwendung: -# ./scripts/run-tests.sh [ISSUE_NUMMER] +# Verwendung (im Container): +# docker compose exec -u www-data web ./scripts/run-tests.sh [ISSUE_NUMMER] # # Parameter: # ISSUE_NUMMER (optional) Nummer des aktuell bearbeiteten Issues, @@ -13,7 +13,7 @@ # Umgebungsvariablen: # CODEBERG_TOKEN Codeberg-API-Token (für Issue-Erstellung) # -# Exit-Code: 0 = alle Tests OK, 1 = Fehler gefunden +# Exit-Code: 0 = alle Tests OK, 1 = Fehler gefunden (oder Auswertung fehlgeschlagen) # ============================================================ set -euo pipefail @@ -39,22 +39,42 @@ fi # ── Tests ausführen ────────────────────────────────────────── echo "" echo "Führe Tests aus..." -JSON_OUTPUT=$($PHP tests/run.php --json 2>&1) || true -EXIT_CODE=$? +# STDERR wird bewusst NICHT mitgefangen: tests/run.php schreibt im --json-Modus +# etwaige PHP-Warnungen/-Notices dorthin, damit STDOUT ausschließlich das JSON +# enthält (siehe tests/run.php). STDERR landet direkt im Terminal. +JSON_OUTPUT=$($PHP tests/run.php --json) && EXIT_CODE=0 || EXIT_CODE=$? # Auch die lesbare Ausgabe zeigen $PHP tests/run.php || true # ── Ergebnis auswerten ─────────────────────────────────────── -FAILED=$(echo "$JSON_OUTPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('failed',0))" 2>/dev/null || echo "0") -FAILURES_JSON=$(echo "$JSON_OUTPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d.get('failures',[])))" 2>/dev/null || echo "[]") - -if [ "$FAILED" = "0" ] || [ "$FAILED" = "" ]; then +if [ "$EXIT_CODE" -eq 0 ]; then echo "" echo "✓ Alle Tests bestanden. Commit kann fortgesetzt werden." exit 0 fi +# tests/run.php meldet Fehlschläge (oder einen sonstigen Fehler) über den +# Exit-Code. Die Details holen wir uns per PHP aus dem JSON – lässt sich die +# Ausgabe nicht auswerten, bricht das Skript hier ab (set -e), statt +# fälschlich Erfolg zu melden. +FAILED=$(echo "$JSON_OUTPUT" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + if (!is_array($d) || !array_key_exists("failed", $d)) { + fwrite(STDERR, "Ungültige JSON-Ausgabe von tests/run.php --json\n"); + exit(1); + } + echo $d["failed"]; +') +FAILURES_JSON=$(echo "$JSON_OUTPUT" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + if (!is_array($d) || !array_key_exists("failures", $d)) { + fwrite(STDERR, "Ungültige JSON-Ausgabe von tests/run.php --json\n"); + exit(1); + } + echo json_encode($d["failures"]); +') + echo "" echo "✗ ${FAILED} Test(s) fehlgeschlagen." @@ -63,7 +83,10 @@ if [ -z "${CODEBERG_TOKEN:-}" ]; then echo "" echo "Hinweis: CODEBERG_TOKEN nicht gesetzt – keine automatischen Issues erstellt." echo "Fehlgeschlagene Tests:" - echo "$FAILURES_JSON" | python3 -c "import json,sys; [print(' •', f) for f in json.load(sys.stdin)]" 2>/dev/null || true + echo "$FAILURES_JSON" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + foreach ((is_array($d) ? $d : []) as $f) { echo " • {$f}\n"; } + ' exit 1 fi @@ -79,51 +102,83 @@ while IFS= read -r failure; do # Titel kürzen (max. 100 Zeichen) TITLE="[BUG] Test fehlgeschlagen: ${failure:0:80}" - # Body zusammenbauen - BODY="## Automatisch erkannter Testfehler\n\n" - BODY+="**Fehlermeldung:** \`${failure}\`\n\n" - BODY+="**Erkannt am:** $(date '+%Y-%m-%d %H:%M:%S')\n\n" - BODY+="**Commit:** $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')\n\n" - + # Body zusammenbauen (mit echten Zeilenumbrüchen, kein "\n"-Text) + BODY_LINES=( + "## Automatisch erkannter Testfehler" + "" + "**Fehlermeldung:** \`${failure}\`" + "" + "**Erkannt am:** $(date '+%Y-%m-%d %H:%M:%S')" + "" + "**Commit:** $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')" + "" + ) if [ -n "$CURRENT_ISSUE" ]; then - BODY+="Dieser Fehler wurde beim Bearbeiten von Issue #${CURRENT_ISSUE} entdeckt.\n\n" + BODY_LINES+=("Dieser Fehler wurde beim Bearbeiten von Issue #${CURRENT_ISSUE} entdeckt." "") fi + BODY_LINES+=( + "### Schritte zur Reproduktion" + "1. \`docker compose exec -u www-data web php tests/run.php\` ausführen" + "2. Fehler tritt im oben genannten Test auf" + "" + "_Dieser Issue wurde automatisch durch den Test-Runner erstellt._" + "_Das Label 'automate' wurde bewusst NICHT gesetzt (Human-in-the-loop)._" + ) + BODY=$(printf '%s\n' "${BODY_LINES[@]}") - BODY+="### Schritte zur Reproduktion\n" - BODY+="1. \`docker compose exec -u www-data web php tests/run.php\` ausführen\n" - BODY+="2. Fehler tritt im oben genannten Test auf\n\n" - BODY+="_Dieser Issue wurde automatisch durch den Test-Runner erstellt._\n" - BODY+="_Das Label 'automate' wurde bewusst NICHT gesetzt (Human-in-the-loop)._" + PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" php -r ' + echo json_encode([ + "title" => getenv("TITLE"), + "body" => getenv("BODY"), + "labels" => [], + ]); + ') RESPONSE=$(curl -s -X POST \ -H "Authorization: token $CODEBERG_TOKEN" \ -H "Content-Type: application/json" \ - -d "$(python3 -c "import json; print(json.dumps({'title': '${TITLE}', 'body': '${BODY}', 'labels': []}))")" \ + -d "$PAYLOAD" \ "${API_BASE}/repos/${REPO}/issues") - ISSUE_NR=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('number','?'))" 2>/dev/null || echo "?") + ISSUE_NR=$(echo "$RESPONSE" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + echo (is_array($d) && isset($d["number"])) ? $d["number"] : "?"; + ') CREATED_ISSUES+=("#${ISSUE_NR}") echo " → Issue #${ISSUE_NR} erstellt: ${failure:0:60}..." -done < <(echo "$FAILURES_JSON" | python3 -c "import json,sys; [print(f) for f in json.load(sys.stdin)]" 2>/dev/null) +done < <(echo "$FAILURES_JSON" | php -r ' + $d = json_decode(stream_get_contents(STDIN), true); + foreach ((is_array($d) ? $d : []) as $f) { echo $f, "\n"; } +') # ── Kommentar im aktuellen Issue hinterlassen ───────────────── if [ -n "$CURRENT_ISSUE" ] && [ ${#CREATED_ISSUES[@]} -gt 0 ]; then ISSUE_LINKS=$(printf '%s, ' "${CREATED_ISSUES[@]}") ISSUE_LINKS="${ISSUE_LINKS%, }" - COMMENT="### Automatische Testläufe\n\n" - COMMENT+="Beim Bearbeiten dieses Issues wurden **${FAILED} Test(s)** fehlgeschlagen.\n\n" - COMMENT+="Folgende Bug-Issues wurden automatisch erstellt (ohne 'automate'-Label – bitte manuell prüfen):\n\n" + COMMENT_LINES=( + "### Automatische Testläufe" + "" + "Beim Bearbeiten dieses Issues wurden **${FAILED} Test(s)** fehlgeschlagen." + "" + "Folgende Bug-Issues wurden automatisch erstellt (ohne 'automate'-Label – bitte manuell prüfen):" + "" + ) for issue in "${CREATED_ISSUES[@]}"; do - COMMENT+="- ${issue}\n" + COMMENT_LINES+=("- ${issue}") done - COMMENT+="\n_Commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')_" + COMMENT_LINES+=("" "_Commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'unbekannt')_") + COMMENT=$(printf '%s\n' "${COMMENT_LINES[@]}") + + PAYLOAD=$(COMMENT="$COMMENT" php -r ' + echo json_encode(["body" => getenv("COMMENT")]); + ') curl -s -X POST \ -H "Authorization: token $CODEBERG_TOKEN" \ -H "Content-Type: application/json" \ - -d "$(python3 -c "import json; print(json.dumps({'body': '${COMMENT}'}))")" \ + -d "$PAYLOAD" \ "${API_BASE}/repos/${REPO}/issues/${CURRENT_ISSUE}/comments" > /dev/null echo "" diff --git a/src/Views/suppliers/list.php b/src/Views/suppliers/list.php index a301e1b..e72977d 100644 --- a/src/Views/suppliers/list.php +++ b/src/Views/suppliers/list.php @@ -1,3 +1,5 @@ + +
diff --git a/src/version.txt b/src/version.txt index eefae6e..e3d6081 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260802 +1-260821 diff --git a/tests/99_ViewKlassenImportTest.php b/tests/99_ViewKlassenImportTest.php new file mode 100644 index 0000000..9b9af4a --- /dev/null +++ b/tests/99_ViewKlassenImportTest.php @@ -0,0 +1,96 @@ +assert($viewDir !== false, 'src/Views/ existiert'); + + $dateien = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($viewDir)); + $geprueft = 0; + $fehler = []; + + foreach ($dateien as $datei) { + if (!$datei->isFile() || $datei->getExtension() !== 'php') { + continue; + } + + $inhalt = file_get_contents($datei->getPathname()); + $relativ = str_replace($viewDir . '/', '', $datei->getPathname()); + $geprueft++; + + foreach ($this->unaufgeloesteKlassen($inhalt) as $klasse) { + $fehler[] = "$relativ verwendet $klasse:: ohne Import"; + } + } + + $this->assertGreaterThan(20, $geprueft, 'Es wurden Views eingelesen'); + $this->assert( + $fehler === [], + 'Jede View importiert die Klassen, die sie statisch aufruft' + . ($fehler === [] ? '' : ': ' . implode('; ', $fehler)) + ); + } + + /** + * Liefert die in $inhalt statisch aufgerufenen Klassennamen, die weder + * global existieren noch per use-Statement importiert sind. + * + * @return string[] + */ + private function unaufgeloesteKlassen(string $inhalt): array + { + preg_match_all( + '/\buse\s+([A-Za-z0-9_\\\\]+)(?:\s+as\s+([A-Za-z0-9_]+))?\s*;/i', + $inhalt, + $useTreffer, + PREG_SET_ORDER + ); + + $importiert = []; + foreach ($useTreffer as $treffer) { + $teile = explode('\\', $treffer[1]); + $name = $treffer[2] ?? end($teile); + $importiert[$name] = true; + } + + // Aufrufe wie View::formatDate(), aber nicht \Steuerkram\View::, + // $obj::, self::/static::/parent:: + preg_match_all('/(?])([A-Z][A-Za-z0-9_]*)\s*::/', $inhalt, $aufrufe); + + $offen = []; + foreach (array_unique($aufrufe[1]) as $klasse) { + if (isset($importiert[$klasse])) { + continue; + } + // Global existierende Klassen (DateTime, PDO, …) sind unkritisch. + if (class_exists($klasse, false) || interface_exists($klasse, false) || enum_exists($klasse, false)) { + continue; + } + if (class_exists('\\' . $klasse) || interface_exists('\\' . $klasse)) { + continue; + } + $offen[] = $klasse; + } + + return $offen; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 0b4ac15..7050725 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,14 @@ abstract class TestCase private static int $totalFailed = 0; private static array $allFailures = []; + /** Unterdrückt die Fortschrittsausgabe (z. B. im --json-Modus von tests/run.php). */ + private static bool $quiet = false; + + public static function setQuiet(bool $quiet): void + { + self::$quiet = $quiet; + } + protected int $passed = 0; protected int $failed = 0; protected array $failures = []; @@ -95,6 +103,8 @@ abstract class TestCase continue; } if (!self::isInsideCleanupRoot($path)) { + // Nicht durch $quiet unterdrückt: Test 79 prüft diese Meldung gezielt + // über ob_start()/ob_get_clean() ihrer eigenen TestCase-Instanz. echo " \e[33m!\e[0m Nicht aufgeräumt (außerhalb der Testverzeichnisse): {$path}\n"; continue; } @@ -145,11 +155,15 @@ abstract class TestCase { if ($condition) { $this->passed++; - echo " \e[32m✓\e[0m {$message}\n"; + if (!self::$quiet) { + echo " \e[32m✓\e[0m {$message}\n"; + } } else { $this->failed++; $this->failures[] = "[{$this->testName}] {$message}"; - echo " \e[31m✗\e[0m {$message}\n"; + if (!self::$quiet) { + echo " \e[31m✗\e[0m {$message}\n"; + } } } @@ -185,7 +199,9 @@ abstract class TestCase public function execute(): array { - echo "\n\e[1m{$this->testName}\e[0m\n"; + if (!self::$quiet) { + echo "\n\e[1m{$this->testName}\e[0m\n"; + } $this->setUp(); try { $this->run(); @@ -193,15 +209,19 @@ abstract class TestCase $this->failed++; $msg = "[{$this->testName}] Ausnahme: " . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(); $this->failures[] = $msg; - echo " \e[31m✗\e[0m Unerwartete Ausnahme: " . $e->getMessage() . "\n"; + if (!self::$quiet) { + echo " \e[31m✗\e[0m Unerwartete Ausnahme: " . $e->getMessage() . "\n"; + } } finally { $this->tearDown(); } - $status = $this->failed === 0 - ? "\e[32mBestanden ({$this->passed}/{$this->passed})\e[0m" - : "\e[31m{$this->failed} Fehler, {$this->passed} OK\e[0m"; - echo " → {$status}\n"; + if (!self::$quiet) { + $status = $this->failed === 0 + ? "\e[32mBestanden ({$this->passed}/{$this->passed})\e[0m" + : "\e[31m{$this->failed} Fehler, {$this->passed} OK\e[0m"; + echo " → {$status}\n"; + } self::$totalPassed += $this->passed; self::$totalFailed += $this->failed; diff --git a/tests/e2e/specs/rechnung-export.spec.js b/tests/e2e/specs/rechnung-export.spec.js index 2066c29..e35e9dd 100644 --- a/tests/e2e/specs/rechnung-export.spec.js +++ b/tests/e2e/specs/rechnung-export.spec.js @@ -28,7 +28,7 @@ async function entwurfAnlegen(page, { tag, einzelpreis = '100.00', ustSatz = '19 await page.fill('#betreff', tag) // Position über den "+"-Button und das Popup hinzufügen. - await page.click('button[onclick^="showAddLineDialog"]') + await page.click('button[data-action="show-add-line-dialog"]') await page.click('#add-line-popup >> text=Neue Position') const zeile = page.locator('#invoice-lines-body tr').last() diff --git a/tests/run.php b/tests/run.php index 7646dea..4724a07 100644 --- a/tests/run.php +++ b/tests/run.php @@ -28,6 +28,14 @@ require __DIR__ . '/TestCase.php'; \Steuerkram\Config::load(); $jsonMode = in_array('--json', $argv ?? [], true); +TestCase::setQuiet($jsonMode); + +// PHP-Warnungen/-Notices einzelner Tests (z. B. absichtlich provozierte +// Fehlerfälle) dürfen die JSON-Ausgabe auf STDOUT nicht verunreinigen – +// scripts/run-tests.sh wertet sie sonst nicht mehr auswertbar aus. +if ($jsonMode) { + ini_set('display_errors', 'stderr'); +} /** * Erfasst den Bestand unterhalb der Datenverzeichnisse.