221 lines
7 KiB
PHP
221 lines
7 KiB
PHP
<?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 = [];
|
||
|
||
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)) {
|
||
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++;
|
||
echo " \e[32m✓\e[0m {$message}\n";
|
||
} else {
|
||
$this->failed++;
|
||
$this->failures[] = "[{$this->testName}] {$message}";
|
||
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
|
||
{
|
||
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;
|
||
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";
|
||
|
||
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,
|
||
];
|
||
}
|
||
}
|