Konten, Kontengruppen, Kategorien und Klassen samt Übernahme aus dem
QIF-Export des Lexware FinanzManagers.
Kontenübersicht nach Kontengruppen gebündelt, mit Gruppensummen und
Gesamtsumme; die drei Saldospalten (Online-Saldo, aktueller und erwarteter
Saldo) sind angelegt und werden bis Phase 2 aus dem Anfangssaldo gespeist.
Kategorien sind hierarchisch mit ":" als Trenner. Der vollständige Pfad
steht redundant in der Tabelle, weil jede Anzeige und der gesamte Import
darüber laufen; Umbenennen und Verschieben schreiben den Teilbaum neu. Die
Art (Einnahme/Ausgabe) gilt für den ganzen Ast. Register wie im Original:
Alle, Einnahmen, Ausgaben, Umbuchungskonten.
QifParser liest zustandsbehaftet und sequenziell, wandelt ISO-8859-1 nach
UTF-8 und versteht US-Beträge sowie das Datumsformat aus der Kopfzeile.
QifStammdatenImport ist idempotent und bietet Trockenlauf und Protokoll -
im Dialog unter Verwaltung sowie über scripts/qif-import.php. Am echten
Export geprüft: 17 Klassen, 105 Kategorien, 37 Konten.
Enthält außerdem die Korrektur der englischen Monatsnamen im
Einstellungsdialog: date('F') ist sprachlich nicht umschaltbar, deshalb
liegen die Namen jetzt zentral als View::MONATE vor. Die Änderung ließ
sich nicht als eigener Commit trennen, weil sie dieselben Dateien berührt
wie Phase 1.
Tests: 227 PHP-Prüfungen und 14 Playwright-Tests, alle grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
7.2 KiB
PHP
235 lines
7.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Basis-Testklasse.
|
||
*
|
||
* Jeder Test läuft in einer Transaktion, die in tearDown() zurückgerollt wird –
|
||
* die Datenbank bleibt danach unverändert.
|
||
*/
|
||
abstract class TestCase
|
||
{
|
||
private static int $gesamtBestanden = 0;
|
||
private static int $gesamtFehler = 0;
|
||
private static array $alleFehler = [];
|
||
|
||
protected int $bestanden = 0;
|
||
protected int $fehlgeschlagen = 0;
|
||
protected array $fehler = [];
|
||
protected string $testName = '';
|
||
|
||
/** @var string[] Im Test angelegte Pfade, die tearDown() wieder entfernt. */
|
||
private array $tempPfade = [];
|
||
|
||
abstract public function run(): void;
|
||
|
||
public function fuehreAus(): void
|
||
{
|
||
$this->setUp();
|
||
try {
|
||
$this->run();
|
||
} catch (\Throwable $e) {
|
||
$this->fehlgeschlagen++;
|
||
$this->fehler[] = sprintf(
|
||
'Ausnahme: %s (%s:%d)',
|
||
$e->getMessage(),
|
||
basename($e->getFile()),
|
||
$e->getLine()
|
||
);
|
||
} finally {
|
||
$this->tearDown();
|
||
}
|
||
|
||
self::$gesamtBestanden += $this->bestanden;
|
||
self::$gesamtFehler += $this->fehlgeschlagen;
|
||
foreach ($this->fehler as $meldung) {
|
||
self::$alleFehler[] = ($this->testName ?: static::class) . ': ' . $meldung;
|
||
}
|
||
}
|
||
|
||
protected function setUp(): void
|
||
{
|
||
\Finanzmanager\Database::beginTransaction();
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
try {
|
||
\Finanzmanager\Database::rollBack();
|
||
} catch (\Throwable) {
|
||
// Transaktion war eventuell nicht aktiv.
|
||
}
|
||
|
||
// Der Rollback dreht nur die Datenbank zurück; im Einstellungs-Cache
|
||
// stünden sonst Werte, die es nicht mehr gibt.
|
||
\Finanzmanager\Models\Einstellung::cacheLeeren();
|
||
\Finanzmanager\Mandant::setze(null);
|
||
|
||
$this->raeumeTempPfadeAuf();
|
||
}
|
||
|
||
/**
|
||
* Legt einen Benutzer an und macht ihn zum Mandanten. Stammdatentests
|
||
* brauchen das: Konten, Kategorien und Klassen hängen an einer benutzer_id.
|
||
* Der Rollback in tearDown() räumt ihn wieder weg.
|
||
*/
|
||
protected function mandantAnlegen(): int
|
||
{
|
||
$id = \Finanzmanager\Models\Benutzer::anlegen(
|
||
'test_' . bin2hex(random_bytes(4)),
|
||
'testpasswort-' . bin2hex(random_bytes(4))
|
||
);
|
||
\Finanzmanager\Mandant::setze($id);
|
||
|
||
return $id;
|
||
}
|
||
|
||
// ── Assertions ───────────────────────────────────────────────────────────
|
||
|
||
protected function assert(bool $bedingung, string $beschreibung): void
|
||
{
|
||
if ($bedingung) {
|
||
$this->bestanden++;
|
||
return;
|
||
}
|
||
$this->fehlgeschlagen++;
|
||
$this->fehler[] = $beschreibung;
|
||
}
|
||
|
||
protected function assertEquals(mixed $erwartet, mixed $ist, string $beschreibung): void
|
||
{
|
||
$this->assert(
|
||
$erwartet === $ist,
|
||
sprintf('%s (erwartet: %s, war: %s)', $beschreibung, self::alsText($erwartet), self::alsText($ist))
|
||
);
|
||
}
|
||
|
||
protected function assertNotEmpty(mixed $wert, string $beschreibung): void
|
||
{
|
||
$this->assert(!empty($wert), $beschreibung . ' (war leer)');
|
||
}
|
||
|
||
protected function assertNull(mixed $wert, string $beschreibung): void
|
||
{
|
||
$this->assert($wert === null, $beschreibung . ' (war nicht null: ' . self::alsText($wert) . ')');
|
||
}
|
||
|
||
protected function assertGreaterThan(float|int $minimum, float|int $wert, string $beschreibung): void
|
||
{
|
||
$this->assert($wert > $minimum, sprintf('%s (%s ist nicht größer als %s)', $beschreibung, $wert, $minimum));
|
||
}
|
||
|
||
// ── Temporäre Pfade ──────────────────────────────────────────────────────
|
||
|
||
protected function registriereTempPfad(string $pfad): string
|
||
{
|
||
$this->tempPfade[] = $pfad;
|
||
return $pfad;
|
||
}
|
||
|
||
protected function tempPfad(string $endung = ''): string
|
||
{
|
||
return $this->registriereTempPfad(
|
||
sys_get_temp_dir() . '/finanzmanager_test_' . bin2hex(random_bytes(6)) . $endung
|
||
);
|
||
}
|
||
|
||
protected function raeumeTempPfadeAuf(): void
|
||
{
|
||
// Umgekehrte Reihenfolge: verschachtelte Pfade vor ihren Elternverzeichnissen.
|
||
foreach (array_reverse($this->tempPfade) as $pfad) {
|
||
if (!file_exists($pfad) && !is_link($pfad)) {
|
||
continue;
|
||
}
|
||
if (!self::liegtImTempBereich($pfad)) {
|
||
echo " ! Nicht aufgeräumt (außerhalb der Testverzeichnisse): {$pfad}\n";
|
||
continue;
|
||
}
|
||
self::entferneRekursiv($pfad);
|
||
}
|
||
$this->tempPfade = [];
|
||
}
|
||
|
||
/** Sicherheitsnetz: gelöscht wird ausschließlich unterhalb der Temp-Verzeichnisse. */
|
||
private static function liegtImTempBereich(string $pfad): bool
|
||
{
|
||
$echt = realpath($pfad);
|
||
if ($echt === false) {
|
||
$eltern = realpath(dirname($pfad));
|
||
if ($eltern === false) {
|
||
return false;
|
||
}
|
||
$echt = $eltern . '/' . basename($pfad);
|
||
}
|
||
|
||
foreach ([sys_get_temp_dir(), \Finanzmanager\Config::get('import_dir')] as $wurzel) {
|
||
$wurzelEcht = is_string($wurzel) && $wurzel !== '' ? realpath($wurzel) : false;
|
||
if ($wurzelEcht !== false && str_starts_with($echt, $wurzelEcht . '/')) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static function entferneRekursiv(string $pfad): void
|
||
{
|
||
if (is_dir($pfad) && !is_link($pfad)) {
|
||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||
if ($eintrag !== '.' && $eintrag !== '..') {
|
||
self::entferneRekursiv($pfad . '/' . $eintrag);
|
||
}
|
||
}
|
||
@rmdir($pfad);
|
||
return;
|
||
}
|
||
@unlink($pfad);
|
||
}
|
||
|
||
// ── Ergebnisse ───────────────────────────────────────────────────────────
|
||
|
||
public function name(): string
|
||
{
|
||
return $this->testName !== '' ? $this->testName : static::class;
|
||
}
|
||
|
||
public function bestanden(): int
|
||
{
|
||
return $this->bestanden;
|
||
}
|
||
|
||
public function fehlgeschlagen(): int
|
||
{
|
||
return $this->fehlgeschlagen;
|
||
}
|
||
|
||
public function fehlermeldungen(): array
|
||
{
|
||
return $this->fehler;
|
||
}
|
||
|
||
public static function gesamtBestanden(): int
|
||
{
|
||
return self::$gesamtBestanden;
|
||
}
|
||
|
||
public static function gesamtFehler(): int
|
||
{
|
||
return self::$gesamtFehler;
|
||
}
|
||
|
||
public static function alleFehler(): array
|
||
{
|
||
return self::$alleFehler;
|
||
}
|
||
|
||
private static function alsText(mixed $wert): string
|
||
{
|
||
return match (true) {
|
||
is_bool($wert) => $wert ? 'true' : 'false',
|
||
is_null($wert) => 'null',
|
||
is_array($wert) => json_encode($wert, JSON_UNESCAPED_UNICODE) ?: 'array',
|
||
default => (string)$wert,
|
||
};
|
||
}
|
||
}
|