Steuerkram/tests/TestCase.php
fds2610-bot 68ce22894f Fix #195: run-tests.sh meldet keinen falschen Erfolg mehr bei Testfehlern
python3-Abhängigkeit entfernt (Auswertung jetzt per php -r), Exit-Code
von tests/run.php wird korrekt ausgewertet statt mit || true verworfen.
--json-Modus gibt zusätzlich keine Fortschrittsanzeige/Warnungen mehr
auf STDOUT aus, damit die JSON-Ausgabe überhaupt parsbar ist.
2026-08-21 09:30:34 +02:00

241 lines
7.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* Basis-Testklasse für Steuerkram-Tests.
*
* Jeder Test läuft in einer MySQL-Transaktion, die am Ende zurückgerollt wird.
* Damit bleibt die Datenbank nach dem Test unverändert.
*/
abstract class TestCase
{
private static int $totalPassed = 0;
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 = [];
protected string $testName = '';
/** @var string[] Im Test angelegte Pfade, die tearDown() wieder entfernt. */
private array $tempPaths = [];
abstract public function run(): void;
protected function setUp(): void
{
\Steuerkram\Database::beginTransaction();
}
protected function tearDown(): void
{
try {
\Steuerkram\Database::rollBack();
} catch (\Throwable) {
// Transaktion war evtl. nicht aktiv
}
$this->cleanupTempPaths();
}
/**
* Meldet einen Pfad zum automatischen Aufräumen an; tearDown() entfernt ihn
* anschließend rekursiv, auch wenn der Test mit einer Ausnahme abbricht.
*
* Nötig, weil der Rollback nur die Datenbank zurückdreht: eine im Test
* vergebene Belegnummer wird danach erneut vergeben, ein liegengebliebenes
* Verzeichnis blockiert dann den echten Upload.
*/
protected function registerTempPath(string $path): string
{
$this->tempPaths[] = $path;
return $path;
}
/**
* Legt ein Verzeichnis an und meldet es zum Aufräumen an.
*/
protected function makeTempDir(string $path, int $mode = 0755): string
{
if (is_dir($path)) {
return $this->registerTempPath($path);
}
// Auch die von mkdir() nebenbei angelegten Elternverzeichnisse anmelden –
// sonst bleibt etwa ein leeres Jahresverzeichnis zurück.
$oberstesNeue = $path;
while (!is_dir(dirname($oberstesNeue)) && dirname($oberstesNeue) !== $oberstesNeue) {
$oberstesNeue = dirname($oberstesNeue);
}
mkdir($path, $mode, true);
$this->registerTempPath($path);
if ($oberstesNeue !== $path) {
$this->registerTempPath($oberstesNeue);
}
return $path;
}
/**
* Liefert einen eindeutigen, noch nicht existierenden Pfad im System-Temp-
* Verzeichnis und meldet ihn zum Aufräumen an.
*/
protected function tempPath(string $suffix = ''): string
{
$name = 'steuerkram_test_' . bin2hex(random_bytes(6)) . $suffix;
return $this->registerTempPath(sys_get_temp_dir() . '/' . $name);
}
protected function cleanupTempPaths(): void
{
// Umgekehrte Reihenfolge: verschachtelte Pfade vor ihren Elternverzeichnissen
foreach (array_reverse($this->tempPaths) as $path) {
if (!file_exists($path) && !is_link($path)) {
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;
}
self::removeRecursive($path);
}
$this->tempPaths = [];
}
/**
* Sicherheitsnetz: entfernt wird ausschließlich unterhalb der Temp- und
* Datenverzeichnisse – und nie eines dieser Verzeichnisse selbst.
*/
private static function isInsideCleanupRoot(string $path): bool
{
$real = realpath($path);
if ($real === false) {
$real = realpath(dirname($path));
if ($real === false) return false;
$real .= '/' . basename($path);
}
foreach ([sys_get_temp_dir(), \Steuerkram\Config::get('upload_dir'), \Steuerkram\Config::get('logo_dir')] as $root) {
$rootReal = is_string($root) && $root !== '' ? realpath($root) : false;
if ($rootReal !== false && str_starts_with($real, $rootReal . '/')) {
return true;
}
}
return false;
}
private static function removeRecursive(string $path): void
{
if (is_link($path) || !is_dir($path)) {
@unlink($path);
return;
}
// Tests setzen Verzeichnisse teils schreibgeschützt, um Fehlerfälle zu prüfen
@chmod($path, 0755);
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
self::removeRecursive($path . '/' . $entry);
}
@rmdir($path);
}
protected function assert(bool $condition, string $message): void
{
if ($condition) {
$this->passed++;
if (!self::$quiet) {
echo " \e[32m✓\e[0m {$message}\n";
}
} else {
$this->failed++;
$this->failures[] = "[{$this->testName}] {$message}";
if (!self::$quiet) {
echo " \e[31m✗\e[0m {$message}\n";
}
}
}
protected function assertEquals(mixed $expected, mixed $actual, string $message): void
{
$this->assert(
$expected == $actual,
"{$message} (erwartet: " . json_encode($expected) . ", erhalten: " . json_encode($actual) . ")"
);
}
protected function assertGreaterThan(int $min, mixed $actual, string $message): void
{
$this->assert($actual > $min, "{$message} (Wert: {$actual}, muss > {$min})");
}
protected function assertNotEmpty(mixed $value, string $message): void
{
$this->assert(!empty($value), $message);
}
/**
* Liefert Konto-ID anhand der Kontonummer oder null wenn nicht gefunden.
*/
protected function findAccountId(string $kontonummer): ?int
{
$row = \Steuerkram\Database::fetchOne(
'SELECT id FROM accounts WHERE kontonummer = ?',
[$kontonummer]
);
return $row ? (int)$row['id'] : null;
}
public function execute(): array
{
if (!self::$quiet) {
echo "\n\e[1m{$this->testName}\e[0m\n";
}
$this->setUp();
try {
$this->run();
} catch (\Throwable $e) {
$this->failed++;
$msg = "[{$this->testName}] Ausnahme: " . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine();
$this->failures[] = $msg;
if (!self::$quiet) {
echo " \e[31m✗\e[0m Unerwartete Ausnahme: " . $e->getMessage() . "\n";
}
} finally {
$this->tearDown();
}
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;
self::$allFailures = array_merge(self::$allFailures, $this->failures);
return $this->failures;
}
public static function globalSummary(): array
{
return [
'passed' => self::$totalPassed,
'failed' => self::$totalFailed,
'failures' => self::$allFailures,
];
}
}