Die offenen Vorgänge „im Bargeld-Tool nicht gefunden“ und „Bargeld-Tool- Prüfung fehlgeschlagen“ führen in den Buchungsbericht mit dem Filter „Bargeld-Tool“ – dort steht die Spalte Kategorie/Gegenkonto mit. Die eigene Liste unter /verwaltung/bargeld/fehler entfällt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
740 lines
30 KiB
PHP
740 lines
30 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Einstellung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\Auswertung;
|
||
use Finanzmanager\Services\BargeldAbgleich;
|
||
use Finanzmanager\Services\BargeldTool;
|
||
use Finanzmanager\Services\Cockpit;
|
||
use Finanzmanager\Services\Crypto;
|
||
|
||
/**
|
||
* Der Abgleich mit dem Bargeld-Tool: Konfiguration, Kategorienauswahl, der
|
||
* Abruf selbst und der Status, den das Kontoblatt als Symbol zeigt.
|
||
*
|
||
* Der Abruf wird gegen einen echten HTTP-Server geprüft – den eingebauten
|
||
* Server von PHP mit einem Router, der sich wie das Bargeld-Tool verhält.
|
||
* Nachgebildete Antworten allein würden genau das übergehen, woran ein Abruf
|
||
* scheitert: Basic Auth, HTTP-Status, ein Rumpf, der kein JSON ist. Lässt sich
|
||
* der Server nicht starten, entfallen die Netzprüfungen; alles Übrige läuft
|
||
* ohne ihn.
|
||
*/
|
||
class BargeldAbgleichTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 34: Abgleich mit dem Bargeld-Tool';
|
||
|
||
private int $konto = 0;
|
||
private int $bargeldKategorie = 0;
|
||
private int $andereKategorie = 0;
|
||
|
||
/** @var resource|null */
|
||
private $server = null;
|
||
private string $adresse = '';
|
||
|
||
public function run(): void
|
||
{
|
||
$this->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();
|
||
$this->buchungsberichtFiltertNachBargeldStatus();
|
||
}
|
||
|
||
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('<html>Fehlerseite</html>');
|
||
$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('#<th class="bargeldspalte"[^>]*>Bar</th>\s*<th class="rechts">Betrag</th>#', $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'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Statt einer eigenen Liste führt der offene Vorgang des Cockpit in den
|
||
* Buchungsbericht, gefiltert auf den Status des Bargeld-Tools (#131).
|
||
* Dort steht – anders als in der früheren Liste – die Kategorie dabei.
|
||
*/
|
||
private function buchungsberichtFiltertNachBargeldStatus(): void
|
||
{
|
||
Einstellung::set('bargeld_url', 'https://bargeld.example.org', 'bargeld');
|
||
|
||
$fehler = $this->buchung(-27.30, '2026-06-01');
|
||
Buchung::zuordnen($fehler, $this->bargeldKategorie, null);
|
||
$fehlt = $this->buchung(-8.40, '2026-06-02');
|
||
Buchung::zuordnen($fehlt, $this->bargeldKategorie, null);
|
||
Database::execute(
|
||
"UPDATE buchungen SET bargeld_status = 'fehler', bargeld_hinweis = 'Kasse steht still.' WHERE id = ?",
|
||
[$fehler]
|
||
);
|
||
Database::execute(
|
||
"UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'Kein Treffer.' WHERE id = ?",
|
||
[$fehlt]
|
||
);
|
||
|
||
$pfade = array_column(Cockpit::offeneVorgaenge('2026-06-30'), 'pfad', 'schluessel');
|
||
foreach (['bargeld_fehler' => 'fehler', 'bargeld_fehlt' => 'fehlt'] as $schluessel => $status) {
|
||
$this->assertNotEmpty($pfade[$schluessel] ?? '', "Der offene Vorgang $schluessel erscheint");
|
||
parse_str((string)parse_url($pfade[$schluessel] ?? '', PHP_URL_QUERY), $query);
|
||
$this->assertEquals(
|
||
'/auswertungen/bericht',
|
||
parse_url($pfade[$schluessel] ?? '', PHP_URL_PATH),
|
||
"Der Vorgang $schluessel führt in die Auswertung"
|
||
);
|
||
$this->assertEquals('buchungsbericht', $query['typ'] ?? '', "Der Vorgang $schluessel öffnet den Buchungsbericht");
|
||
$this->assertEquals('alles', $query['zeitraum'] ?? '', 'Über den gesamten Zeitraum');
|
||
$this->assertEquals($status, $query['bargeld'] ?? '', "Gefiltert auf den Status $status");
|
||
|
||
$bericht = Auswertung::bericht(Auswertung::normalisiere($query), '2026-06-30');
|
||
$buchungen = array_merge(...array_column($bericht['gruppen'], 'buchungen'));
|
||
$ids = array_column($buchungen, 'buchung_id');
|
||
$this->assert(
|
||
in_array($status === 'fehler' ? $fehler : $fehlt, $ids, true),
|
||
"Der Bericht enthält die Buchung mit Status $status"
|
||
);
|
||
$this->assert(
|
||
!in_array($status === 'fehler' ? $fehlt : $fehler, $ids, true),
|
||
'Die Buchung mit dem anderen Status fehlt'
|
||
);
|
||
$this->assertEquals(
|
||
['Bargeld Haushalt'],
|
||
array_values(array_unique(array_column($buchungen, 'kategorie'))),
|
||
'Die Kategorie steht an jeder Buchung'
|
||
);
|
||
|
||
$html = $this->rendere('auswertungen/teil-buchungen.php', ['bericht' => $bericht]);
|
||
$this->assert(str_contains($html, '<th>Kategorie</th>'), 'Die Spalte Kategorie steht im Bericht');
|
||
$this->assert(str_contains($html, 'Bargeld Haushalt'), 'Die Kategorie der Buchung steht im Bericht');
|
||
$this->assert(str_contains($html, 'Bargeld Giro'), 'Das Konto steht im Bericht');
|
||
$this->assert(
|
||
str_contains($html, $status === 'fehler' ? 'Kasse steht still.' : 'Kein Treffer.'),
|
||
'Die Meldung der Gegenstelle steht am Symbol'
|
||
);
|
||
$this->assert(str_contains($html, 'stößt die Prüfung erneut an'), 'Der Hinweis zur erneuten Prüfung steht dabei');
|
||
}
|
||
|
||
$this->assert(
|
||
!is_file(dirname(__DIR__) . '/src/Views/verwaltung/bargeld_fehler.php'),
|
||
'Die frühere eigene Liste ist entfernt'
|
||
);
|
||
}
|
||
|
||
/** @param list<array<string,mixed>> $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<string,mixed> $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'
|
||
<?php
|
||
|
||
// Ein Bargeld-Tool im Kleinformat – nur so viel, wie der Abgleich sieht.
|
||
$pfad = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
$benutzer = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||
if ($benutzer === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||
$roh = base64_decode(substr((string)$_SERVER['HTTP_AUTHORIZATION'], 6), true);
|
||
$benutzer = $roh === false ? '' : (string)strstr($roh, ':', true);
|
||
}
|
||
if ($benutzer !== 'kasse') {
|
||
http_response_code(401);
|
||
echo json_encode(['fehler' => '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;
|
||
}
|
||
}
|