Finanzmanager/tests/29_ImportExportTest.php
fds2610-bot 2c86e8522b Import und Export in QIF und CSV, Housekeeping für Importdateien
Aus der Kachel QIF-Import wird Import/Export mit je einem Knopf für beide
Richtungen; davor steht die Wahl des Dateiformats (QIF oder CSV), die als
Dialog und ohne Skript als eigene Seite funktioniert.

- CsvBuchungsImport/CsvTabelle: Buchungen samt Splitzeilen, Spalten über die
  Kopfzeile, dieselbe Dublettenerkennung und der Trockenlauf wie beim QIF.
- BuchungsExport/ExportController: Filter nach Zeitraum, Konto, Kategorie und
  Klasse; die Datei entsteht im Speicher, ein Exportverzeichnis gibt es nicht.
- Housekeeping: Hochgeladene Importdateien und data/tmp werden nach der
  eingestellten Frist gelöscht (Vorgabe 14 Tage, 0 schaltet ab).

Migration 013, Test 29 sowie E2E-Spec importexport.spec.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:01:30 +02:00

559 lines
27 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);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\BuchungsExport;
use Finanzmanager\Services\CsvBuchungsImport;
use Finanzmanager\Services\CsvTabelle;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\Services\Housekeeping;
use Finanzmanager\Services\QifBuchungsImport;
use Finanzmanager\Services\QifParser;
use Finanzmanager\Services\QifStammdatenImport;
use Finanzmanager\Controllers\ImportController;
/**
* Import und Export in beiden Formaten.
*
* Der Prüfstein ist der Rundlauf: Was der Export schreibt, muss der eigene
* Import wieder einlesen können – und dabei dieselben Buchungen ergeben, nicht
* Dubletten. Dazu die Filter (Zeitraum, Konto, Kategorie, Klasse), die
* Formatwahl vor beiden Dialogen und das Housekeeping, das die hochgeladenen
* Dateien wieder wegräumt.
*/
class ImportExportTest extends TestCase
{
protected string $testName = 'Test 29: Import und Export (QIF und CSV)';
private int $giro = 0;
private int $tagesgeld = 0;
private int $strom = 0;
private int $wasser = 0;
private int $haus = 0;
private int $privat = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->buchungen();
$this->csvTabelleLiestUndSchreibt();
$this->csvExportFuehrtSplitsUndUmbuchungen();
$this->filterGreifenEinzelnUndGemeinsam();
$this->qifExportTraegtDenDialektDesFinanzManagers();
$this->qifRundlaufUeberEinenZweitenMandanten();
$this->csvRundlaufUeberEinenDrittenMandanten();
$this->csvImportErkenntDublettenUndFehler();
$this->housekeepingRaeumtAlteDateienWeg();
$this->dateinamenUndFormate();
$this->exportLegtKeineDateiAb();
$this->verwaltungFuehrtImportUndExport();
}
// ── Aufbau ───────────────────────────────────────────────────────────────
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->tagesgeld = Konto::anlegen([
'name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->strom = Kategorie::findenOderAnlegenNachPfad('Wohnen:Strom');
$this->wasser = Kategorie::findenOderAnlegenNachPfad('Wohnen:Wasser');
$this->haus = Klasse::findenOderAnlegen('Haus 1');
$this->privat = Klasse::findenOderAnlegen('Privat');
}
private function buchungen(): void
{
// Eine gewöhnliche Buchung mit Kategorie und Klasse …
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-03-18', 'betrag' => -84.00,
'empfaenger' => 'Stadtwerke', 'buchungstext' => 'Abschlag März', 'nummer' => '042',
'kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'abgeglichen' => 1,
]);
// … eine Umbuchung auf das zweite Konto …
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-03-20', 'betrag' => -500.00,
'empfaenger' => 'Eigenübertrag', 'buchungstext' => 'Rücklage',
'gegenkonto_id' => $this->tagesgeld,
]);
// … und eine Splitbuchung, deren Zeilen zwei Kategorien tragen.
Buchung::anlegen(
[
'konto_id' => $this->giro, 'datum' => '2026-04-02', 'betrag' => -110.00,
'empfaenger' => 'Versorger', 'buchungstext' => 'Jahresabrechnung',
],
[
['kategorie_id' => $this->strom, 'klasse_id' => $this->haus, 'betrag' => -80.00, 'notiz' => 'Strom'],
['kategorie_id' => $this->wasser, 'klasse_id' => $this->privat, 'betrag' => -30.00, 'notiz' => 'Wasser'],
]
);
}
// ── Das Dateiformat selbst ───────────────────────────────────────────────
private function csvTabelleLiestUndSchreibt(): void
{
$text = CsvTabelle::schreibe([
['Feld', 'Mit;Trenner', 'Mit"Zitat'],
['Wert', 'zwei', 'drei'],
]);
$this->assert(str_starts_with($text, CsvTabelle::BOM), 'Die Datei beginnt mit einem BOM');
$this->assert(str_contains($text, '"Mit;Trenner"'), 'Ein Feld mit Trenner wird maskiert');
$this->assert(str_contains($text, '"Mit""Zitat"'), 'Anführungszeichen werden verdoppelt');
$this->assert(str_contains($text, "\r\n"), 'Zeilen enden mit CRLF');
$zeilen = CsvTabelle::lies($text);
$this->assertEquals(2, count($zeilen), 'Die geschriebene Datei liest sich wieder ein');
$this->assertEquals('Mit;Trenner', $zeilen[0][1], 'Der maskierte Trenner überlebt den Rundlauf');
$this->assertEquals('Mit"Zitat', $zeilen[0][2], 'Das Anführungszeichen überlebt den Rundlauf');
// Fremde Dateien: Komma als Trenner, Punkt als Dezimalzeichen.
$fremd = CsvTabelle::datensaetze("Datum,Konto,Betrag\r\n2026-03-18,Giro,\"1,234.56\"\r\n");
$this->assertEquals(1, count($fremd), 'Auch eine Datei mit Komma-Trenner wird gelesen');
$this->assertEquals('2026-03-18', $fremd[0]['datum'], 'Die Kopfzeile wird zum Schlüssel');
$this->assertEquals(1234.56, CsvTabelle::betrag('1,234.56'), 'Betrag im englischen Format');
$this->assertEquals(-1234.56, CsvTabelle::betrag('-1.234,56'), 'Betrag im deutschen Format');
$this->assertEquals(-84.0, CsvTabelle::betrag('-84,00 €'), 'Währungszeichen stört nicht');
$this->assertEquals(0.0, CsvTabelle::betrag(''), 'Ein leeres Feld ist null');
$this->assertEquals('2026-03-18', CsvTabelle::datum('18.03.2026'), 'Datum als TT.MM.JJJJ');
$this->assertEquals('2026-03-18', CsvTabelle::datum('2026-03-18'), 'Datum als ISO');
$this->assertEquals('1998-03-18', CsvTabelle::datum('18.03.98'), 'Zweistellige Jahre ab 70 sind 19xx');
$this->assertNull(CsvTabelle::datum('31.02.2026'), 'Ein Datum, das es nicht gibt, fällt heraus');
$this->assertNull(CsvTabelle::datum('irgendwann'), 'Text ist kein Datum');
}
// ── Export ───────────────────────────────────────────────────────────────
private function csvExportFuehrtSplitsUndUmbuchungen(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
$zeilen = CsvTabelle::lies($csv);
$this->assertEquals(BuchungsExport::SPALTEN, $zeilen[0], 'Die Kopfzeile nennt alle Spalten');
// Drei Buchungen, dazu zwei Splitzeilen – plus Kopfzeile.
$arten = array_count_values(array_column($zeilen, 0));
$this->assertEquals(3, $arten[BuchungsExport::ART_BUCHUNG] ?? 0, 'Drei Buchungen im Export');
$this->assertEquals(2, $arten[BuchungsExport::ART_SPLIT] ?? 0, 'Zwei Splitzeilen im Export');
$this->assert(str_contains($csv, '18.03.2026'), 'Das Datum steht deutsch in der Datei');
$this->assert(str_contains($csv, '-84,00'), 'Der Betrag steht mit Komma in der Datei');
$this->assert(str_contains($csv, 'Wohnen:Strom'), 'Die Kategorie steht als Pfad in der Datei');
$this->assert(str_contains($csv, 'Exportrücklage'), 'Die Umbuchung nennt ihr Gegenkonto');
// Die Splitzeilen folgen unmittelbar auf ihre Buchung.
$split = null;
foreach ($zeilen as $nr => $zeile) {
if ($zeile[0] === BuchungsExport::ART_SPLIT) {
$split = $nr;
break;
}
}
$this->assertNotEmpty($split, 'Es gibt eine Splitzeile');
$this->assertEquals(
BuchungsExport::ART_BUCHUNG,
$zeilen[$split - 1][0],
'Über der ersten Splitzeile steht ihre Buchung'
);
}
private function filterGreifenEinzelnUndGemeinsam(): void
{
// Gezählt wird über COUNT, geladen über dieselbe Bedingung – geprüft
// wird deshalb beides gegeneinander.
$anzahl = function (array $filter): int {
$geladen = count(BuchungsExport::buchungen($filter));
$this->assertEquals($geladen, BuchungsExport::anzahl($filter), 'Zählen und Laden sind sich einig');
return $geladen;
};
$this->assertEquals(3, $anzahl([]), 'Ohne Filter kommt alles mit');
$this->assertEquals(2, $anzahl(['von' => '2026-03-19']), 'Der Zeitraum grenzt nach vorn ab');
$this->assertEquals(2, $anzahl(['bis' => '2026-03-31']), 'Der Zeitraum grenzt nach hinten ab');
$this->assertEquals(1, $anzahl(['von' => '2026-03-19', 'bis' => '2026-03-31']), 'Beide Grenzen zusammen');
// Das Tagesgeld führt selbst keine Buchung – die Umbuchung steht beim
// Girokonto. Sie muss trotzdem kommen, sonst fehlte dem Auszug die
// einzige Bewegung des Kontos.
$this->assertEquals(
1,
$anzahl(['konten' => [$this->tagesgeld]]),
'Der Kontofilter holt die Umbuchung von der anderen Seite'
);
$this->assertEquals(3, $anzahl(['konten' => [$this->giro]]), 'Das erfassende Konto führt alle drei');
// Die Kategorie steckt bei der Splitbuchung allein in den Zeilen.
$this->assertEquals(2, $anzahl(['kategorien' => [$this->strom]]), 'Kategorie: Buchung und Splitzeile');
$this->assertEquals(1, $anzahl(['kategorien' => [$this->wasser]]), 'Kategorie nur in einer Splitzeile');
$this->assertEquals(2, $anzahl(['klassen' => [$this->haus]]), 'Klasse: Buchung und Splitzeile');
$this->assertEquals(1, $anzahl(['klassen' => [$this->privat]]), 'Klasse nur in einer Splitzeile');
$this->assertEquals(
1,
$anzahl(['kategorien' => [$this->strom], 'bis' => '2026-03-31']),
'Kategorie und Zeitraum wirken zusammen'
);
$this->assertEquals(0, $anzahl(['von' => '2027-01-01']), 'Ein leerer Zeitraum liefert nichts');
}
private function qifExportTraegtDenDialektDesFinanzManagers(): void
{
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
$this->assert(str_contains($qif, '!Type:Class'), 'Das QIF führt die Klassen');
$this->assert(str_contains($qif, '!Type:Cat'), 'Das QIF führt die Kategorien');
$this->assert(str_contains($qif, '!Option:AutoSwitch'), 'Das QIF führt die Kontenliste');
$this->assert(str_contains($qif, 'T-84.00'), 'Beträge stehen im QIF im US-Format');
$this->assert(str_contains($qif, 'D18.03.2026'), 'Das Datum folgt der Angabe !Option:DMY');
$this->assert(str_contains($qif, 'LWohnen:Strom/Haus 1'), 'Kategorie und Klasse stehen in einer L-Zeile');
$this->assert(str_contains($qif, 'L[Exportrücklage]'), 'Die Umbuchung nennt ihr Gegenkonto in Klammern');
$this->assert(str_contains($qif, 'SWohnen:Wasser/Privat'), 'Die Splitzeile trägt Kategorie und Klasse');
$this->assert(str_contains($qif, '$-30.00'), 'Die Splitzeile trägt ihren Betrag');
$parser = new QifParser($qif);
$konten = QifBuchungsImport::kontenDerDatei($parser);
$this->assertEquals(3, $parser->zusammenfassung()['Bank'] ?? 0, 'Der eigene Parser findet drei Buchungen');
$this->assert(isset($konten['Exportgiro']), 'Der Parser erkennt das Girokonto');
$this->assert(
isset($konten['Exportrücklage']),
'Auch das Umbuchungsziel steht in der Kontenliste des Parsers'
);
}
// ── Import ───────────────────────────────────────────────────────────────
/**
* Der Rundlauf über einen frischen Mandanten: Stammdaten und Buchungen
* müssen sich aus der Exportdatei allein wiederherstellen lassen.
*/
private function qifRundlaufUeberEinenZweitenMandanten(): void
{
$qif = BuchungsExport::qif(BuchungsExport::buchungen([]));
$this->mandantAnlegen();
$stammdaten = (new QifStammdatenImport(new QifParser($qif)))->ausfuehren();
$this->assertEquals(2, $stammdaten['konten']['neu'], 'Beide Konten entstehen aus der Datei');
$this->assertEquals(2, $stammdaten['klassen']['neu'], 'Beide Klassen entstehen aus der Datei');
$this->assertGreaterThan(2, $stammdaten['kategorien']['neu'], 'Der Kategoriebaum entsteht mit');
$lauf = ['stichtag' => '2026-01-01', 'bis' => '2026-12-31'];
$ergebnis = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Alle drei Buchungen kommen an');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Rundlauf läuft fehlerfrei');
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
// Der zweite Lauf derselben Datei darf nichts verdoppeln.
$zweiter = (new QifBuchungsImport(new QifParser($qif), $lauf))->ausfuehren();
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
[$umbuchung, $split] = $this->umbuchungUndSplit();
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
$this->assertEquals(
'Wohnen:Wasser',
$split['splits'][1]['kategoriepfad'],
'Die zweite Splitzeile trägt ihre Kategorie'
);
}
private function csvRundlaufUeberEinenDrittenMandanten(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
// Ein weiterer Mandant mit gleichnamigen Konten: So lässt sich prüfen,
// dass der Import wirklich anlegt, statt nur Dubletten zu zählen.
$this->mandantAnlegen();
Konto::anlegen(['name' => 'Exportgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1]);
Konto::anlegen(['name' => 'Exportrücklage', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1]);
$vorschau = (new CsvBuchungsImport($csv))->vorschau();
$this->assertEquals(3, $vorschau['buchungen']['neu'], 'Der Trockenlauf zählt drei neue Buchungen');
$this->assertEquals(0, Buchung::anzahl(), 'Der Trockenlauf hat nichts gespeichert');
$ergebnis = (new CsvBuchungsImport($csv))->ausfuehren();
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Der echte Lauf legt drei Buchungen an');
$this->assertEquals(2, $ergebnis['buchungen']['splitzeilen'], 'Beide Splitzeilen kommen mit');
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Der Import läuft fehlerfrei');
$this->assertEquals(3, Buchung::anzahl(), 'Drei Buchungen stehen in der Datenbank');
// Kategorien und Klassen bringt der Import mit, Konten nicht.
$this->assertNotEmpty(Kategorie::findeNachPfad('Wohnen:Strom'), 'Die Kategorie wurde ergänzt');
$this->assertNotEmpty(Klasse::findeNachName('Haus 1'), 'Die Klasse wurde ergänzt');
$wieder = BuchungsExport::csv(BuchungsExport::buchungen([]));
$this->assertEquals(
count(CsvTabelle::lies($csv)),
count(CsvTabelle::lies($wieder)),
'Der erneute Export hat genauso viele Zeilen wie der erste'
);
// Umbuchung und Splitbuchung müssen den Weg heil überstanden haben.
[$umbuchung, $split] = $this->umbuchungUndSplit();
$this->assertNotEmpty($umbuchung, 'Die Umbuchung kam als Umbuchung an');
$this->assertNotEmpty($split, 'Die Splitbuchung kam mit Zeilen an');
$this->assertEquals(2, count($split['splits'] ?? []), 'Die Splitbuchung hat wieder zwei Zeilen');
$this->assertEquals('Wohnen:Wasser', $split['splits'][1]['kategoriepfad'], 'Die zweite Zeile trägt ihre Kategorie');
$this->assertEquals('Privat', $split['splits'][1]['klassenname'], 'Die zweite Zeile trägt ihre Klasse');
}
private function csvImportErkenntDublettenUndFehler(): void
{
$csv = BuchungsExport::csv(BuchungsExport::buchungen([]));
$zweiter = (new CsvBuchungsImport($csv))->ausfuehren();
$this->assertEquals(0, $zweiter['buchungen']['neu'], 'Der zweite Lauf legt nichts an');
$this->assertEquals(3, $zweiter['buchungen']['vorhanden'], 'Alle drei gelten als vorhanden');
$this->assertEquals(3, Buchung::anzahl(), 'Die Zahl der Buchungen bleibt gleich');
// Ein unbekanntes Konto legt der Import nicht an – die Zeile fällt aus.
$fremd = "Art;Datum;Konto;Betrag\r\nBuchung;01.05.2026;Gibtesnicht;-1,00\r\n";
$lauf = (new CsvBuchungsImport($fremd))->vorschau();
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Ein unbekanntes Konto ist ein Fehler');
$this->assertEquals(0, $lauf['buchungen']['neu'], 'Aus der Zeile wird keine Buchung');
$this->assertNotEmpty($lauf['meldungen'], 'Das Protokoll nennt den Grund');
// Ohne Pflichtspalte lässt sich gar nichts einlesen.
$ohneKonto = "Art;Datum;Betrag\r\nBuchung;01.05.2026;-1,00\r\n";
$meldung = '';
try {
(new CsvBuchungsImport($ohneKonto))->vorschau();
} catch (\Throwable $e) {
$meldung = $e->getMessage();
}
$this->assert(str_contains($meldung, 'Spalten'), 'Eine fehlende Pflichtspalte bricht den Lauf ab');
// Eine Splitzeile ohne Buchung davor ist ein Fehler, kein stiller Verlust.
$verwaist = "Art;Datum;Konto;Betrag\r\nSplitzeile;;;-1,00\r\n";
$lauf = (new CsvBuchungsImport($verwaist))->vorschau();
$this->assertEquals(1, $lauf['buchungen']['fehler'], 'Eine verwaiste Splitzeile wird gemeldet');
}
// ── Housekeeping ─────────────────────────────────────────────────────────
private function housekeepingRaeumtAlteDateienWeg(): void
{
// Ein eigenes Verzeichnis unterhalb des Importverzeichnisses: Housekeeping
// arbeitet auf einem Pfad relativ zum Projekt, und der Aufräumer der
// Testbasis löscht nur dort (und im Temp-Verzeichnis) etwas.
$name = 'hk_' . bin2hex(random_bytes(4));
$verzeichnis = $this->registriereTempPfad(\Finanzmanager\Config::get('import_dir') . '/' . $name);
mkdir($verzeichnis, 0o775, true);
$alt = $this->registriereTempPfad($verzeichnis . '/alt.qif');
$neu = $this->registriereTempPfad($verzeichnis . '/neu.qif');
file_put_contents($alt, '!Type:Bank');
file_put_contents($neu, '!Type:Bank');
touch($alt, time() - 30 * 86400);
// Das überwachte Verzeichnis kommt aus den Einstellungen; für den Test
// zeigt es auf den frisch angelegten Pfad. Der Rollback dreht die
// Einstellung wieder zurück.
Einstellung::set('import_qif_verzeichnis', 'data/import/' . $name, 'import');
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '14', 'import');
Einstellung::set(Housekeeping::EINSTELLUNG_LAUF, '', 'import');
$this->assertEquals(14, Housekeeping::tage(), 'Die Frist kommt aus den Einstellungen');
$this->assert(Housekeeping::faellig(), 'Ohne vorherigen Lauf ist das Aufräumen fällig');
$probe = Housekeeping::ausfuehren(true);
$this->assertEquals(1, $probe['geloescht'], 'Der Probelauf zählt die alte Datei');
$this->assert(is_file($alt), 'Der Probelauf löscht nichts');
$ergebnis = Housekeeping::ausfuehren();
$this->assertEquals(1, $ergebnis['geloescht'], 'Der echte Lauf löscht die alte Datei');
$this->assertGreaterThan(0, $ergebnis['bytes'], 'Der Lauf meldet den freigewordenen Platz');
$this->assert(!is_file($alt), 'Die alte Datei ist weg');
$this->assert(is_file($neu), 'Die frische Datei bleibt liegen');
$this->assert(!Housekeeping::faellig(), 'Nach dem Lauf steht heute nichts mehr an');
$this->assertNull(Housekeeping::beiGelegenheit(), 'Der beiläufige Lauf hält sich daran');
// Frist 0 schaltet das Aufräumen ab.
Einstellung::set(Housekeeping::EINSTELLUNG_TAGE, '0', 'import');
touch($neu, time() - 400 * 86400);
$aus = Housekeeping::ausfuehren();
$this->assertEquals(0, $aus['geloescht'], 'Mit Frist 0 wird nichts gelöscht');
$this->assert(is_file($neu), 'Auch eine uralte Datei bleibt dann liegen');
$this->assert(!Housekeeping::faellig(), 'Abgeschaltet ist nie etwas fällig');
}
// ── Formate, Dateinamen und Oberfläche ───────────────────────────────────
private function dateinamenUndFormate(): void
{
$this->assertEquals(['qif', 'csv'], array_keys(Dateiformat::ALLE), 'Zwei Formate, QIF zuerst');
$this->assertEquals('qif', Dateiformat::pruefe('QIF'), 'Die Prüfung ist unempfindlich für Großschreibung');
$this->assertNull(Dateiformat::pruefe('xlsx'), 'Ein fremdes Format fällt heraus');
$this->assertEquals('csv', Dateiformat::ausDateiname('Export 2026.CSV'), 'Das Format steckt in der Endung');
$this->assertNull(Dateiformat::ausDateiname('böse.php'), 'Eine fremde Endung ergibt kein Format');
$this->assertEquals(
'buchungen-2026-01-01-bis-2026-12-31.csv',
BuchungsExport::dateiname('csv', ['von' => '2026-01-01', 'bis' => '2026-12-31']),
'Der Dateiname nennt Format und Zeitraum'
);
$this->assert(
str_ends_with(BuchungsExport::dateiname('qif', []), '.qif'),
'Ohne Zeitraum bleibt wenigstens die Endung richtig'
);
// Der Upload nimmt jetzt beide Formate an – und nur die.
$this->assertEquals('Export.CSV', ImportController::sichererDateiname('Export.CSV'), 'CSV wird angenommen');
$this->assertEquals('Export.QIF', ImportController::sichererDateiname('Export.QIF'), 'QIF bleibt erlaubt');
$this->assertNull(ImportController::sichererDateiname('böse.php'), 'Fremde Endung wird abgewiesen');
$this->assertEquals(
'passwd.csv',
ImportController::sichererDateiname('../../etc/passwd.csv'),
'Pfadanteile fallen auch beim CSV weg'
);
}
private function exportLegtKeineDateiAb(): void
{
$vorher = $this->dateienImImportverzeichnis();
$datei = BuchungsExport::datei('csv', []);
$this->assertNotEmpty($datei['inhalt'], 'Die Datei entsteht im Speicher');
$this->assertEquals(3, $datei['anzahl'], 'Sie meldet, wie viele Buchungen sie führt');
$this->assert(str_contains($datei['typ'], 'text/csv'), 'Der Inhaltstyp passt zum Format');
$this->assertEquals(
$vorher,
$this->dateienImImportverzeichnis(),
'Der Export hinterlässt keine Datei auf der Platte'
);
$meldung = '';
try {
BuchungsExport::datei('xlsx', []);
} catch (\Throwable $e) {
$meldung = $e->getMessage();
}
$this->assertNotEmpty($meldung, 'Ein unbekanntes Format wird abgewiesen');
}
private function verwaltungFuehrtImportUndExport(): void
{
$seite = $this->verwaltungsseite();
$this->assert(str_contains($seite, '<h2>Import/Export</h2>'), 'Die Kachel heißt Import/Export');
$this->assert(!str_contains($seite, '<h2>QIF-Import</h2>'), 'Die alte Beschriftung ist weg');
$this->assert(
(bool)preg_match('#href="/verwaltung/import" class="knopf klein[^"]*" data-formatwahl="Import"#', $seite),
'Der Importknopf öffnet die Formatwahl'
);
$this->assert(
(bool)preg_match('#href="/verwaltung/export" class="knopf klein[^"]*" data-formatwahl="Export"#', $seite),
'Der Exportknopf öffnet die Formatwahl'
);
// Die Karten stehen im Markup, damit das Skript ohne Formatliste auskommt.
foreach (array_keys(Dateiformat::ALLE) as $format) {
$this->assert(
str_contains($seite, 'data-format="' . $format . '"'),
'Der Auswahldialog führt eine Karte für ' . strtoupper($format)
);
}
$skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js');
$this->assert(str_contains($skript, 'data-formatwahl'), 'Das Skript kennt die Formatknöpfe');
$this->assert(str_contains($skript, 'showModal'), 'Es öffnet den Dialog');
// Ohne Skript führt derselbe Knopf auf die Auswahl als eigene Seite.
$wahl = $this->rendere('verwaltung/formatwahl.php', [
'richtung' => 'Export',
'ziel' => '/verwaltung/export',
'einleitung' => 'Welches Format?',
]);
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=qif"'), 'Die Seite führt zum QIF-Export');
$this->assert(str_contains($wahl, 'href="/verwaltung/export?format=csv"'), 'Die Seite führt zum CSV-Export');
}
// ── Hilfen ───────────────────────────────────────────────────────────────
/**
* Die Umbuchung und die Splitbuchung des aktuellen Mandanten.
*
* @return array{0: array<string,mixed>|null, 1: array<string,mixed>|null}
*/
private function umbuchungUndSplit(): array
{
$umbuchung = null;
$split = null;
foreach (BuchungsExport::buchungen([]) as $buchung) {
if (($buchung['gegenkontoname'] ?? '') === 'Exportrücklage') {
$umbuchung = $buchung;
}
if ($buchung['splits'] !== []) {
$split = $buchung;
}
}
return [$umbuchung, $split];
}
/** @return list<string> */
private function dateienImImportverzeichnis(): array
{
$pfad = dirname(__DIR__) . '/' . trim(
(string)Einstellung::get('import_qif_verzeichnis', 'data/import'),
'/'
);
$dateien = glob($pfad . '/*') ?: [];
sort($dateien);
return $dateien;
}
private function verwaltungsseite(): string
{
$seite = $this->rendere('verwaltung/index.php', [
'aktuellerPfad' => '/verwaltung',
'zahlen' => [
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
'konten' => 0, 'kontengruppen' => 0, 'reihen' => 0, 'erinnerungen' => 0,
'vertraege' => 0, 'fristen' => 0,
],
]);
return substr($seite, (int)strpos($seite, '<main'));
}
/** @param array<string,mixed> $daten */
private function rendere(string $view, array $daten): string
{
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x'];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/' . $view;
return (string)ob_get_clean();
}
}