mandantAnlegen(); $this->stammdaten(); $this->antwortAuswerten(); $this->basisadresseKuerztDenSchnittstellenpfad(); $this->kategorienauswahl(); $this->ohneAdresseKeinAbgleich(); $this->passwortStehtVerschluesselt(); $this->suchfensterBleibtInGrenzen(); $this->mitServer(); $this->statusAufraeumenNimmtDenHakenWeg(); $this->kontoblattFuehrtDenStatusMit(); $this->kontoblattZeigtDieViervierSymbole(); $this->konfigurationsseiteZeigtDieAuswahl(); } protected function tearDown(): void { $this->serverBeenden(); parent::tearDown(); } private function stammdaten(): void { $this->konto = Konto::anlegen([ 'name' => 'Bargeld Giro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1, ]); $this->bargeldKategorie = Kategorie::anlegen(['name' => 'Bargeld Haushalt', 'art' => 'ausgabe']); $this->andereKategorie = Kategorie::anlegen(['name' => 'Bargeld Versicherung', 'art' => 'ausgabe']); Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]); } private function buchung(float $betrag = -12.50, string $datum = '2026-03-10'): int { return Buchung::anlegen([ 'konto_id' => $this->konto, 'datum' => $datum, 'betrag' => $betrag, 'empfaenger' => 'Bargeldabhebung', ]); } // ── Auswertung der Antwort, ohne Netz ──────────────────────────────────── private function antwortAuswerten(): void { $treffer = BargeldTool::trefferAus( '{"anzahl":1,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50,' . '"waehrung":"EUR","zweck":"Wocheneinkauf"}]}' ); $this->assert($treffer['gefunden'], 'Eine Buchung in der Antwort gilt als gefunden'); $this->assert(!$treffer['fehler'], 'Ein Treffer ist kein Fehler'); $this->assertEquals(1, $treffer['anzahl'], 'Die Trefferzahl steht im Ergebnis'); $this->assert( str_contains($treffer['meldung'], '08.03.2026') && str_contains($treffer['meldung'], 'Wocheneinkauf'), 'Die Meldung nennt Datum und Zweck des Treffers' ); $leer = BargeldTool::trefferAus('{"anzahl":0,"buchungen":[]}'); $this->assert(!$leer['gefunden'], 'Eine leere Liste heißt: nicht gefunden'); $this->assert(!$leer['fehler'], 'Null Treffer sind kein Fehler'); $mehrere = BargeldTool::trefferAus( '{"anzahl":3,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50}]}' ); $this->assertEquals(3, $mehrere['anzahl'], 'Das Feld „anzahl“ zählt auch abgeschnittene Treffer'); $this->assert(str_contains($mehrere['meldung'], '3 Treffer'), 'Mehrere Treffer stehen in der Meldung'); $kaputt = BargeldTool::trefferAus('Fehlerseite'); $this->assert($kaputt['fehler'], 'Eine Antwort ohne JSON ist ein Fehler'); $this->assert(!$kaputt['gefunden'], 'Eine kaputte Antwort gilt nicht als Treffer'); $ohneFeld = BargeldTool::trefferAus('{"anzahl":1}'); $this->assert($ohneFeld['fehler'], 'Ohne Feld „buchungen“ ist die Antwort unbrauchbar'); } // ── Basisadresse ───────────────────────────────────────────────────────── /** * Wer die Adresse aus dem Browser kopiert, hat `/api/v1` schon dabei. * Bleibt es stehen, fragt der Finanzmanager unter `/api/v1/api/v1/status` * an und der Verbindungstest scheitert mit einem 404. */ private function basisadresseKuerztDenSchnittstellenpfad(): void { $erwartet = 'https://bargeld.example.org'; foreach ([ 'https://bargeld.example.org', 'https://bargeld.example.org/', 'https://bargeld.example.org/api', 'https://bargeld.example.org/api/v1', 'https://bargeld.example.org/api/v1/', ' https://bargeld.example.org/api/v2 ', ] as $eingabe) { $this->assertEquals( $erwartet, BargeldTool::basisadresse($eingabe), 'Basisadresse aus „' . trim($eingabe) . '“' ); } $this->assertEquals( 'https://example.org/fscash', BargeldTool::basisadresse('https://example.org/fscash/api/v1'), 'Ein Unterverzeichnis vor dem Schnittstellenpfad bleibt erhalten' ); $this->assertEquals( 'https://apianbieter.example.org', BargeldTool::basisadresse('https://apianbieter.example.org'), 'Nur ein eigener Pfadteil „api“ wird gekürzt, kein Namensbestandteil' ); } // ── Auswahl der Kategorien ─────────────────────────────────────────────── private function kategorienauswahl(): void { $this->assertEquals( [$this->bargeldKategorie], Kategorie::bargeldAuswahl(), 'Die vorgemerkte Kategorie steht in der Auswahl' ); $kategorie = Kategorie::find($this->bargeldKategorie); $this->assertEquals(1, (int)$kategorie['bargeld_abgleich'], 'Die Vormerkung steht an der Kategorie'); Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie, $this->andereKategorie]); $this->assertEquals(2, count(Kategorie::bargeldAuswahl()), 'Die Auswahl lässt sich erweitern'); Kategorie::bargeldAuswahlSetzen([]); $this->assertEquals([], Kategorie::bargeldAuswahl(), 'Eine leere Liste wählt alles ab'); Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]); $this->assertEquals( [$this->bargeldKategorie], Kategorie::bargeldAuswahl(), 'Die Auswahl wird ersetzt, nicht ergänzt' ); } // ── Ohne Konfiguration bleibt alles ruhig ──────────────────────────────── private function ohneAdresseKeinAbgleich(): void { Einstellung::set('bargeld_url', '', 'bargeld'); Einstellung::cacheLeeren(); $this->assert(!BargeldAbgleich::eingerichtet(), 'Ohne Adresse gilt der Abgleich als nicht eingerichtet'); $id = $this->buchung(); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertNull(BargeldAbgleich::pruefe($id), 'Ohne Adresse wird nicht abgeglichen'); $this->assertNull(Buchung::find($id)['bargeld_status'], 'Ohne Adresse bleibt der Status leer'); } private function passwortStehtVerschluesselt(): void { if (!Crypto::einsatzbereit()) { return; } Einstellung::set('bargeld_passwort', Crypto::verschluessle('geheim123'), 'bargeld'); Einstellung::cacheLeeren(); $gespeichert = (string)Einstellung::get('bargeld_passwort'); $this->assert( Crypto::istVerschluesselt($gespeichert), 'Das Passwort des Bargeld-Tools steht verschlüsselt in der Datenbank' ); $this->assertEquals('geheim123', Crypto::entschluessle($gespeichert), 'Es lässt sich wieder lesen'); } private function suchfensterBleibtInGrenzen(): void { Einstellung::set('bargeld_tage', '', 'bargeld'); Einstellung::cacheLeeren(); $this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Ohne Angabe gilt das Fenster von zehn Tagen'); Einstellung::set('bargeld_tage', '0', 'bargeld'); Einstellung::cacheLeeren(); $this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Null Tage ergäben kein Fenster – der Standard greift'); Einstellung::set('bargeld_tage', '9999', 'bargeld'); Einstellung::cacheLeeren(); $this->assertEquals(365, BargeldAbgleich::fensterTage(), 'Das Fenster wird bei einem Jahr gekappt'); Einstellung::set('bargeld_tage', '5', 'bargeld'); Einstellung::cacheLeeren(); $this->assertEquals(5, BargeldAbgleich::fensterTage(), 'Eine eigene Angabe gilt'); } // ── Mit laufendem Bargeld-Tool ─────────────────────────────────────────── private function mitServer(): void { if (!$this->serverStarten()) { return; } Einstellung::set('bargeld_url', $this->adresse, 'bargeld'); Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld'); Einstellung::set( 'bargeld_passwort', Crypto::einsatzbereit() ? Crypto::verschluessle('offen') : 'offen', 'bargeld' ); Einstellung::cacheLeeren(); $this->assert(BargeldAbgleich::eingerichtet(), 'Mit Adresse gilt der Abgleich als eingerichtet'); $this->verbindungstest(); $this->gefunden(); $this->nichtGefunden(); $this->serverfehler(); $this->falscheZugangsdaten(); $this->umbuchungBleibtUnberuehrt(); $this->andereKategorieBleibtUnberuehrt(); $this->suchfensterWirdMitgeschickt(); } private function verbindungstest(): void { $zugang = BargeldTool::ausEinstellungen(); $this->assertNotEmpty($zugang, 'Aus den Einstellungen entsteht ein Zugang'); $ergebnis = $zugang->test(); $this->assert($ergebnis['erfolg'], 'Der Verbindungstest erreicht das Bargeld-Tool'); $this->assert( str_contains($ergebnis['meldung'], 'FSCash-Test'), 'Der Test nennt Namen und Fassung der Gegenstelle' ); } private function gefunden(): void { $id = $this->buchung(-12.50); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertEquals(BargeldAbgleich::GEFUNDEN, BargeldAbgleich::pruefe($id), 'Der Betrag wird gefunden'); $buchung = Buchung::find($id); $this->assertEquals('gefunden', $buchung['bargeld_status'], 'Der Status steht an der Buchung'); $this->assert( str_contains((string)$buchung['bargeld_hinweis'], 'Wocheneinkauf'), 'Der Treffer steht als Hinweis an der Buchung' ); $this->assertNotEmpty($buchung['bargeld_geprueft_am'], 'Der Zeitpunkt der Prüfung wird festgehalten'); } private function nichtGefunden(): void { $id = $this->buchung(-99.00); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertEquals(BargeldAbgleich::FEHLT, BargeldAbgleich::pruefe($id), 'Ein unbekannter Betrag fehlt'); $this->assertEquals('fehlt', Buchung::find($id)['bargeld_status'], 'Der Status „fehlt“ steht an der Buchung'); } private function serverfehler(): void { $id = $this->buchung(-500.00); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($id), 'HTTP 500 ergibt den Status Fehler'); $buchung = Buchung::find($id); $this->assert( str_contains((string)$buchung['bargeld_hinweis'], 'Kasse steht still'), 'Die Meldung der Gegenstelle steht im Hinweis' ); } private function falscheZugangsdaten(): void { Einstellung::set('bargeld_benutzer', 'niemand', 'bargeld'); Einstellung::cacheLeeren(); $id = $this->buchung(-12.50); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertEquals( BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($id), 'Abgewiesene Zugangsdaten ergeben einen Fehler, kein „nicht gefunden“' ); Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld'); Einstellung::cacheLeeren(); } private function umbuchungBleibtUnberuehrt(): void { $spar = Konto::anlegen([ 'name' => 'Bargeld Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, ]); $id = $this->buchung(-12.50); Buchung::zuordnen($id, null, $spar); $this->assertNull(BargeldAbgleich::pruefe($id), 'Eine Umbuchung wird nicht abgeglichen'); $this->assertNull(Buchung::find($id)['bargeld_status'], 'Eine Umbuchung trägt keinen Status'); } private function andereKategorieBleibtUnberuehrt(): void { $id = $this->buchung(-12.50); Buchung::zuordnen($id, $this->andereKategorie, null); $this->assertNull(BargeldAbgleich::pruefe($id), 'Eine nicht vorgemerkte Kategorie wird nicht abgeglichen'); $this->assertNull(Buchung::find($id)['bargeld_status'], 'Sie bleibt ohne Status – der graue Kreis'); } /** * Das Fenster reicht rückwärts ab dem Buchungsdatum. Der Testserver * antwortet nur, wenn `von` und `bis` genau dazu passen. */ private function suchfensterWirdMitgeschickt(): void { Einstellung::set('bargeld_tage', '10', 'bargeld'); Einstellung::cacheLeeren(); $id = $this->buchung(-7.77, '2026-03-20'); Buchung::zuordnen($id, $this->bargeldKategorie, null); $this->assertEquals( BargeldAbgleich::GEFUNDEN, BargeldAbgleich::pruefe($id), 'Gesucht wird von 2026-03-10 bis 2026-03-20 – zehn Tage rückwärts ab dem Buchungsdatum' ); } // ── Aufräumen und Kontoblatt ───────────────────────────────────────────── private function statusAufraeumenNimmtDenHakenWeg(): void { $id = $this->buchung(-12.50); Buchung::zuordnen($id, $this->bargeldKategorie, null); Finanzmanager\Database::execute( "UPDATE buchungen SET bargeld_status = 'gefunden', bargeld_hinweis = 'x' WHERE id = ?", [$id] ); BargeldAbgleich::statusAufraeumen([$this->bargeldKategorie]); $this->assertEquals( 'gefunden', Buchung::find($id)['bargeld_status'], 'Eine weiterhin vorgemerkte Kategorie behält ihren Status' ); BargeldAbgleich::statusAufraeumen([$this->andereKategorie]); $this->assertNull( Buchung::find($id)['bargeld_status'], 'Wird die Kategorie abgewählt, verschwindet der Status' ); $this->assertNull(Buchung::find($id)['bargeld_hinweis'], 'Der Hinweis verschwindet mit'); } private function kontoblattFuehrtDenStatusMit(): void { $id = $this->buchung(-33.00, '2026-04-01'); Buchung::zuordnen($id, $this->bargeldKategorie, null); Finanzmanager\Database::execute( "UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'nicht da' WHERE id = ?", [$id] ); $zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-04-01', 'bis' => '2026-04-01']); $this->assertEquals(1, count($zeilen), 'Die Buchung steht im Kontoblatt'); $this->assertEquals('fehlt', $zeilen[0]['bargeld_status'], 'Das Kontoblatt liefert den Abgleichstatus mit'); $this->assertEquals('nicht da', $zeilen[0]['bargeld_hinweis'], 'Der Hinweis kommt für den Tooltip mit'); } // ── Die Anzeige ────────────────────────────────────────────────────────── /** * Die schmale Spalte zwischen Klasse und Betrag trägt genau vier Zustände. * Geprüft wird am gerenderten Kontoblatt, nicht an der Absicht. */ private function kontoblattZeigtDieViervierSymbole(): void { $zeilen = []; foreach ([null, 'gefunden', 'fehlt', 'fehler'] as $nr => $status) { $zeilen[] = [ 'id' => 900 + $nr, 'datum' => '2026-05-0' . ($nr + 1), 'valuta' => null, 'betrag' => -10.0, 'empfaenger' => 'Zeile ' . $nr, 'buchungstext' => '', 'nummer' => '', 'abgeglichen' => 0, 'kategorie_id' => $this->bargeldKategorie, 'klasse_id' => null, 'gesplittet' => 0, 'quelle' => 'manuell', 'bargeld_status' => $status, 'bargeld_hinweis' => $status === 'fehler' ? 'Kasse steht still.' : null, 'gegenkonto_id' => null, 'konto_id' => $this->konto, 'gegensicht' => false, 'split_id' => null, 'saldo' => 100.0, ]; } $mitSpalte = $this->kontoblattHtml($zeilen, true); $this->assert( (bool)preg_match('#]*>Bar\s*Betrag#', $mitSpalte), 'Die Abgleichspalte steht zwischen Klasse und Betrag' ); $this->assert((bool)preg_match('#bargeld-neutral"[^>]*>●<#u', $mitSpalte), 'Ohne Status steht dort der graue Kreis'); $this->assert((bool)preg_match('#bargeld-gefunden"[^>]*>✓<#u', $mitSpalte), 'Gefunden ergibt den Haken'); $this->assert((bool)preg_match('#bargeld-fehlt"[^>]*>✗<#u', $mitSpalte), 'Nicht gefunden ergibt das X'); $this->assert((bool)preg_match('#bargeld-fehler"[^>]*>!<#u', $mitSpalte), 'Ein Abrufproblem ergibt das Ausrufezeichen'); $this->assert( str_contains($mitSpalte, 'title="Kasse steht still."'), 'Die Meldung der Gegenstelle steht im Tooltip' ); $stil = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css'); $this->assert( (bool)preg_match('#\.bargeld-gefunden\s*\{[^}]*var\(--positiv\)#', $stil), 'Der Haken nimmt die Farbe des Farbschemas für Positives' ); $this->assert( (bool)preg_match('#\.bargeld-fehlt\s*\{[^}]*var\(--negativ\)#', $stil), 'Das X nimmt die Farbe für Negatives' ); $this->assert( (bool)preg_match('#\.bargeld-fehler\s*\{[^}]*color:\s*inherit#', $stil), 'Das Ausrufezeichen erbt die Textfarbe – je nach Farbschema' ); $ohneSpalte = $this->kontoblattHtml($zeilen, false); $this->assert( !str_contains($ohneSpalte, 'bargeldspalte'), 'Ohne eingerichtetes Bargeld-Tool fehlt die Spalte ganz' ); } private function konfigurationsseiteZeigtDieAuswahl(): void { $html = $this->rendere('verwaltung/bargeld.php', [ 'url' => 'https://kasse.example.net', 'benutzerkennung' => 'kasse', 'hatPasswort' => true, 'tage' => 10, 'kategorien' => Kategorie::baumFlach(), 'auswahl' => [$this->bargeldKategorie], 'schluessel' => true, ]); foreach (['bargeld_url', 'bargeld_benutzer', 'bargeld_passwort', 'bargeld_tage'] as $feld) { $this->assert(str_contains($html, 'name="' . $feld . '"'), 'Das Feld ' . $feld . ' steht im Formular'); } $this->assert( (bool)preg_match( '#name="kategorien\[\]" value="' . $this->bargeldKategorie . '"\s+checked#', $html ), 'Die vorgemerkte Kategorie ist angekreuzt' ); $this->assert( (bool)preg_match( '#name="kategorien\[\]" value="' . $this->andereKategorie . '"\s*>#', $html ), 'Die übrigen Kategorien stehen zur Wahl, aber ohne Kreuz' ); $this->assert( !str_contains($html, 'style="'), 'Kein style-Attribut – die Content-Security-Policy verwirft es' ); $this->assert( str_contains($html, 'action="/verwaltung/bargeld/test"'), 'Der Verbindungstest ist erreichbar' ); } /** @param list> $zeilen */ private function kontoblattHtml(array $zeilen, bool $bargeld): string { return $this->rendere('buchungen/kontoblatt.php', [ 'konto' => Konto::mitSalden(Konto::find($this->konto)), 'zeilen' => $zeilen, 'gesamt' => count($zeilen), 'seite' => 1, 'seiten' => 1, 'filter' => [], 'kategorien' => Kategorie::baumFlach(), 'klassen' => [], 'konten' => Konto::alle(), 'splits' => [], 'wertpapiere' => [], 'bargeld' => $bargeld, ]); } /** * Eine View rendern wie der Controller es täte – ohne Webserver. * * @param array $daten */ private function rendere(string $view, array $daten): string { $daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x', 'aktuellerPfad' => '/verwaltung']; extract($daten, EXTR_SKIP); ob_start(); include dirname(__DIR__) . '/src/Views/' . $view; return (string)ob_get_clean(); } // ── Der Testserver ─────────────────────────────────────────────────────── /** * Der eingebaute PHP-Server mit einem Router, der sich wie das Bargeld-Tool * verhält: Basic Auth, Statusaufruf, Buchungssuche, ein Betrag, der einen * Serverfehler auslöst. */ private function serverStarten(): bool { $verzeichnis = $this->tempPfad(); if (!mkdir($verzeichnis, 0o777, true) && !is_dir($verzeichnis)) { return false; } file_put_contents($verzeichnis . '/router.php', $this->routerQuelltext()); $port = random_int(18000, 18999); $prozess = @proc_open( sprintf('exec php -S 127.0.0.1:%d -t %s %s/router.php', $port, escapeshellarg($verzeichnis), escapeshellarg($verzeichnis)), [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], $rohre ); if (!is_resource($prozess)) { return false; } $this->server = $prozess; $this->adresse = 'http://127.0.0.1:' . $port; // Der Server braucht einen Augenblick, bis er den Port annimmt. for ($versuch = 0; $versuch < 50; $versuch++) { $verbindung = @fsockopen('127.0.0.1', $port, $fehler, $meldung, 0.2); if (is_resource($verbindung)) { fclose($verbindung); return true; } usleep(100_000); } $this->serverBeenden(); return false; } private function serverBeenden(): void { if (is_resource($this->server)) { proc_terminate($this->server); proc_close($this->server); } $this->server = null; } private function routerQuelltext(): string { return <<<'PHP' 'Unbekannte Kennung.']); return true; } if ($pfad === '/api/v1/status') { echo json_encode(['name' => 'FSCash-Test', 'version' => '0.1']); return true; } if ($pfad !== '/api/v1/buchungen') { http_response_code(404); echo json_encode(['fehler' => 'Unbekannter Pfad.']); return true; } $betrag = (string)($_GET['betrag'] ?? ''); $von = (string)($_GET['von'] ?? ''); $bis = (string)($_GET['bis'] ?? ''); if ($betrag === '-500.00') { http_response_code(500); echo json_encode(['fehler' => 'Kasse steht still.']); return true; } // Der Betrag mit dem Prüfdatum: Er wird nur gefunden, wenn das Fenster // genau zehn Tage rückwärts ab dem Buchungsdatum reicht. if ($betrag === '-7.77') { $treffer = ($von === '2026-03-10' && $bis === '2026-03-20') ? [['id' => 'f1', 'datum' => '2026-03-15', 'betrag' => -7.77]] : []; echo json_encode(['anzahl' => count($treffer), 'buchungen' => $treffer]); return true; } if ($betrag === '-12.50') { echo json_encode(['anzahl' => 1, 'buchungen' => [[ 'id' => 'a1', 'datum' => '2026-03-08', 'betrag' => -12.50, 'zweck' => 'Wocheneinkauf', ]]]); return true; } echo json_encode(['anzahl' => 0, 'buchungen' => []]); return true; PHP; } }