Merge pull request 'Feature: lesende API für den Buchungsabgleich (Issue #13)' (#14) from fds2610-bot/FSCash:main into main

Reviewed-on: https://codeberg.org/fds2610/FSCash/pulls/14
This commit is contained in:
fds2610 2026-08-19 07:49:28 +02:00
commit 774f871642
7 changed files with 596 additions and 0 deletions

View file

@ -8,6 +8,10 @@ Dokumentiere Ausgaben on-the-go als Webapp
- [ ] Zusammenfassen von mehreren Einträgen zu einer Aktie - [ ] Zusammenfassen von mehreren Einträgen zu einer Aktie
- [ ] Prüfen ob APIKEYS leer sind und ggfs die Aktualisierung ausblenden - [ ] Prüfen ob APIKEYS leer sind und ggfs die Aktualisierung ausblenden
## V.2.5
- [X] Lesende HTTP-Schnittstelle für den Buchungsabgleich externer Tools (Issue #13): `GET /api/v1/buchungen` sucht Buchungen anhand von Zeitraum, Betrag (mit Toleranz) und Währung; `GET /api/v1/status` dient als Verbindungstest. Beide Endpunkte sind rein lesend, ohne Seiteneffekte, und per HTTP Basic Auth mit den bestehenden FSCash-Zugangsdaten (Tabelle `users`) geschützt. Die Endpunkte benötigen `mod_rewrite` (siehe `html/.htaccess`), da die Pfade ohne `.php`-Endung erreichbar sein müssen. FSCash führt Bargeldausgaben intern als positiven Betrag; die Schnittstelle rechnet das für den Abgleich in die vom Finanzmanager erwartete Konvention um (Ausgaben negativ).
## V.2.4 ## V.2.4
- [X] Buchungszeilen im Dashboard haben ein zusätzliches Feld "abgeglichen" (Standard: nicht abgeglichen). Ein Klick auf die Zeile schaltet den Wert um und speichert ihn sofort in der DB. Abgeglichene Zeilen werden mit grünem Hintergrund dargestellt. - [X] Buchungszeilen im Dashboard haben ein zusätzliches Feld "abgeglichen" (Standard: nicht abgeglichen). Ein Klick auf die Zeile schaltet den Wert um und speichert ihn sofort in der DB. Abgeglichene Zeilen werden mit grünem Hintergrund dargestellt.
@ -114,6 +118,7 @@ Weiteres
4. config_sample.php in config.php umbenennen und DB credentials setzen 4. config_sample.php in config.php umbenennen und DB credentials setzen
5. webroot (z.B. http://localhost/) aufrufen und mit user test / test123 einloggen. 5. webroot (z.B. http://localhost/) aufrufen und mit user test / test123 einloggen.
6. obige Schritte sind nur das Nötigste, um die Funktionalität herzustellen. Insbesondere bei Zugriffsmöglichkeiten aus dem Internet ist es wichtig, https Protokolle, vpns und/oder ggfs zusätzliche Sicherungsmassnahmen (hardening) zu ergreifen. 6. obige Schritte sind nur das Nötigste, um die Funktionalität herzustellen. Insbesondere bei Zugriffsmöglichkeiten aus dem Internet ist es wichtig, https Protokolle, vpns und/oder ggfs zusätzliche Sicherungsmassnahmen (hardening) zu ergreifen.
7. für die lesende API unter `/api/v1/` (siehe Abschnitt V.2.5) muss auf dem Apache-Server `mod_rewrite` aktiviert sein (`a2enmod rewrite`) und `AllowOverride All` für den Webroot gesetzt sein, damit die Regeln aus `html/.htaccess` greifen.
Ausserdem solltet Ihr User registrieren und ggfs. die Kategorien anpassen. Ausserdem solltet Ihr User registrieren und ggfs. die Kategorien anpassen.

View file

@ -2,3 +2,18 @@
<FilesMatch "\.(css|js)$"> <FilesMatch "\.(css|js)$">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0" Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
</FilesMatch> </FilesMatch>
# Lesende API für den Buchungsabgleich (Issue #13): erlaubt den vom
# Finanzmanager erwarteten erweiterungslosen Pfaden /api/v1/buchungen und
# /api/v1/status, auf die tatsächlichen PHP-Dateien zu zeigen. Manche
# SAPIs (z. B. php-fpm) geben den Authorization-Header nicht automatisch an
# PHP weiter, daher wird er hier explizit durchgereicht (für Basic Auth).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteRule ^api/v1/buchungen$ api/v1/buchungen.php [L,QSA]
RewriteRule ^api/v1/status$ api/v1/status.php [L,QSA]
</IfModule>

38
html/api/v1/_auth.php Normal file
View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* Prüft die HTTP-Basic-Auth-Zugangsdaten der lesenden API (Issue #13) gegen
* die bestehende FSCash-Benutzertabelle (dieselben Zugangsdaten wie beim
* Login). Bei fehlender oder falscher Authentifizierung wird sofort eine
* 401-Antwort gesendet und die Ausführung beendet.
*/
function fscash_api_require_auth(PDO $pdo): void
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? null;
$credentials = fscash_parse_basic_auth_header($authHeader);
$authenticated = false;
if ($credentials !== null) {
$stmt = $pdo->prepare('SELECT password FROM users WHERE username = :username');
$stmt->execute(['username' => $credentials['username']]);
$hash = $stmt->fetchColumn();
if ($hash !== false && password_verify($credentials['password'], $hash)) {
$authenticated = true;
}
}
if (!$authenticated) {
header('WWW-Authenticate: Basic realm="FSCash API"');
http_response_code(401);
echo json_encode(
fscash_api_error('Zugangsdaten fehlen oder stimmen nicht.'),
JSON_UNESCAPED_UNICODE
);
exit;
}
}

81
html/api/v1/buchungen.php Normal file
View file

@ -0,0 +1,81 @@
<?php
/**
* GET /api/v1/buchungen — Buchungssuche für den Abgleich im Finanzmanager
* (Issue #13). Rein lesend, keine Seiteneffekte.
*
* FSCash führt Bargeldausgaben intern als positiven Betrag; der
* Finanzmanager sendet und erwartet Ausgaben negativ. Die Umrechnung
* übernimmt fscash_signed_betrag() in html/lib/fscash.php.
*/
declare(strict_types=1);
require __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../lib/fscash.php';
require_once __DIR__ . '/_auth.php';
header('Content-Type: application/json; charset=utf-8');
fscash_api_require_auth($pdo);
$validated = fscash_validate_buchungen_query($_GET);
if ($validated['error'] !== null) {
http_response_code(400);
echo json_encode(fscash_api_error($validated['error']), JSON_UNESCAPED_UNICODE);
exit;
}
// FSCash speichert ausschließlich EUR-Beträge; andere Währungen liefern
// definitionsgemäß keine Treffer (kein Fehler, siehe Issue #13 Abschnitt 4).
if ($validated['waehrung'] !== 'EUR') {
echo json_encode(['anzahl' => 0, 'buchungen' => []], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$matchParams = [
'von' => $validated['von'],
'bis' => $validated['bis'],
'betrag' => $validated['betrag'],
'toleranz' => $validated['toleranz'],
];
$countStmt = $pdo->prepare('
SELECT COUNT(*)
FROM entries e
WHERE e.date BETWEEN :von AND :bis
AND ABS((-1 * e.amount) - :betrag) <= :toleranz
');
$countStmt->execute($matchParams);
$anzahl = (int)$countStmt->fetchColumn();
$selectStmt = $pdo->prepare('
SELECT e.id, e.date AS datum, e.amount AS betrag, e.subject AS zweck,
c.name AS kategorie, pm.name AS konto
FROM entries e
LEFT JOIN categories c ON e.category = c.category_id
LEFT JOIN payment_methods pm ON e.payment_method = pm.payment_method_id
WHERE e.date BETWEEN :von AND :bis
AND ABS((-1 * e.amount) - :betrag) <= :toleranz
ORDER BY e.date DESC, e.id DESC
LIMIT :limit
');
$selectStmt->bindValue(':von', $validated['von']);
$selectStmt->bindValue(':bis', $validated['bis']);
$selectStmt->bindValue(':betrag', $validated['betrag']);
$selectStmt->bindValue(':toleranz', $validated['toleranz']);
$selectStmt->bindValue(':limit', $validated['limit'], PDO::PARAM_INT);
$selectStmt->execute();
$rows = $selectStmt->fetchAll();
echo json_encode([
'anzahl' => $anzahl,
'buchungen' => array_map('fscash_format_buchung', $rows),
], JSON_UNESCAPED_UNICODE);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(
fscash_api_error('Interner Fehler bei der Buchungssuche.'),
JSON_UNESCAPED_UNICODE
);
}

22
html/api/v1/status.php Normal file
View file

@ -0,0 +1,22 @@
<?php
/**
* GET /api/v1/status — Verbindungstest für den Finanzmanager (Issue #13).
*
* Rein lesend, keine Seiteneffekte. Meldet nur, ob Adresse und Zugangsdaten
* stimmen; setzt keine Suchabfrage voraus.
*/
declare(strict_types=1);
require __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../lib/fscash.php';
require_once __DIR__ . '/_auth.php';
header('Content-Type: application/json; charset=utf-8');
fscash_api_require_auth($pdo);
echo json_encode([
'name' => 'FSCash',
'version' => FSCASH_API_VERSION,
], JSON_UNESCAPED_UNICODE);

View file

@ -11,6 +11,11 @@
declare(strict_types=1); declare(strict_types=1);
/**
* Von /api/v1/status gemeldete Versionsnummer (Issue #13).
*/
const FSCASH_API_VERSION = '2.5';
/** /**
* Normalisiert Monat/Jahr, wenn beim Monats-Blättern (< 1 bzw. > 12) die * Normalisiert Monat/Jahr, wenn beim Monats-Blättern (< 1 bzw. > 12) die
* Jahresgrenze überschritten wird. * Jahresgrenze überschritten wird.
@ -231,3 +236,183 @@ function fscash_pending_migrations(array $availableFiles, array $appliedFilename
fn(string $f): bool => !in_array(basename($f), $appliedFilenames, true) fn(string $f): bool => !in_array(basename($f), $appliedFilenames, true)
)); ));
} }
/**
* Baut eine einheitliche Fehler-Antwort für die lesende API (Issue #13).
*
* @return array{fehler:string}
*/
function fscash_api_error(string $message): array
{
return ['fehler' => $message];
}
/**
* Parst den Wert eines Authorization-Headers für HTTP Basic Auth.
*
* @return array{username:string,password:string}|null Null, wenn der Header fehlt oder nicht dem Basic-Schema entspricht.
*/
function fscash_parse_basic_auth_header(?string $headerValue): ?array
{
if ($headerValue === null || stripos($headerValue, 'Basic ') !== 0) {
return null;
}
$decoded = base64_decode(trim(substr($headerValue, 6)), true);
if ($decoded === false || !str_contains($decoded, ':')) {
return null;
}
[$username, $password] = explode(':', $decoded, 2);
return ['username' => $username, 'password' => $password];
}
/**
* Prüft, ob ein String ein gültiges Kalenderdatum im Format JJJJ-MM-TT ist.
*/
function fscash_is_valid_date(string $value): bool
{
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) {
return false;
}
return checkdate((int)$m[2], (int)$m[3], (int)$m[1]);
}
/**
* Validiert die Query-Parameter der Buchungssuche (/api/v1/buchungen, Issue #13).
*
* Reine Validierung ohne DB-Zugriff: prüft Pflichtfelder, Formate und die
* Reihenfolge von 'von'/'bis'. Bei Erfolg ist 'error' null und die übrigen
* Felder sind typisiert und einsatzbereit; sonst ist 'error' die für den
* Finanzmanager lesbare Fehlermeldung (siehe Abschnitt "Fehlerverhalten").
*
* @param array<string,mixed> $query Roher Query-String, z. B. $_GET.
* @return array{error:?string,von:?string,bis:?string,betrag:?float,toleranz:float,waehrung:string,limit:int}
*/
function fscash_validate_buchungen_query(array $query): array
{
$result = [
'error' => null,
'von' => null,
'bis' => null,
'betrag' => null,
'toleranz' => 0.0,
'waehrung' => 'EUR',
'limit' => 20,
];
$von = isset($query['von']) ? trim((string)$query['von']) : '';
if ($von === '') {
$result['error'] = "Parameter 'von' fehlt.";
return $result;
}
if (!fscash_is_valid_date($von)) {
$result['error'] = "Parameter 'von' ist unbrauchbar.";
return $result;
}
$bis = isset($query['bis']) ? trim((string)$query['bis']) : '';
if ($bis === '') {
$result['error'] = "Parameter 'bis' fehlt.";
return $result;
}
if (!fscash_is_valid_date($bis)) {
$result['error'] = "Parameter 'bis' ist unbrauchbar.";
return $result;
}
if ($von > $bis) {
$result['error'] = "Parameter 'von' darf nicht nach 'bis' liegen.";
return $result;
}
$betragRaw = isset($query['betrag']) ? trim((string)$query['betrag']) : '';
if ($betragRaw === '') {
$result['error'] = "Parameter 'betrag' fehlt.";
return $result;
}
if (!preg_match('/^-?\d+(\.\d+)?$/', $betragRaw)) {
$result['error'] = "Parameter 'betrag' ist unbrauchbar.";
return $result;
}
if (isset($query['toleranz']) && trim((string)$query['toleranz']) !== '') {
$toleranzRaw = trim((string)$query['toleranz']);
if (!preg_match('/^\d+(\.\d+)?$/', $toleranzRaw)) {
$result['error'] = "Parameter 'toleranz' ist unbrauchbar.";
return $result;
}
$result['toleranz'] = (float)$toleranzRaw;
}
if (isset($query['waehrung']) && trim((string)$query['waehrung']) !== '') {
$waehrungRaw = trim((string)$query['waehrung']);
if (!preg_match('/^[A-Za-z]{3}$/', $waehrungRaw)) {
$result['error'] = "Parameter 'waehrung' ist unbrauchbar.";
return $result;
}
$result['waehrung'] = strtoupper($waehrungRaw);
}
if (isset($query['limit']) && trim((string)$query['limit']) !== '') {
$limitRaw = trim((string)$query['limit']);
if (!preg_match('/^\d+$/', $limitRaw) || (int)$limitRaw < 1) {
$result['error'] = "Parameter 'limit' ist unbrauchbar.";
return $result;
}
$result['limit'] = (int)$limitRaw;
}
$result['von'] = $von;
$result['bis'] = $bis;
$result['betrag'] = (float)$betragRaw;
return $result;
}
/**
* Rechnet einen in FSCash als positive Bargeldausgabe gespeicherten Betrag in
* die vom Finanzmanager erwartete Vorzeichenkonvention um (Issue #13).
*
* FSCash kennt intern keine Einnahmen und führt Bargeldausgaben stets als
* positiven Betrag. Der Finanzmanager führt Ausgaben negativ, daher wird das
* Vorzeichen für den Abgleich gedreht: eine gesuchte Ausgabe von -12.50 muss
* auf einen in FSCash gespeicherten Betrag von 12.50 treffen.
*/
function fscash_signed_betrag(float $storedAmount): float
{
return -$storedAmount;
}
/**
* Baut aus einer DB-Zeile der Buchungssuche das vom Finanzmanager erwartete
* JSON-Objekt für eine einzelne Buchung (Feldnamen laut Issue #13).
*
* Erwartete Zeilen-Schlüssel: id, datum, betrag (intern positiv gespeichert),
* zweck sowie optional kategorie/konto (null, falls die verknüpfte
* Kategorie/Zahlweise gelöscht wurde).
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
function fscash_format_buchung(array $row): array
{
$buchung = [
'id' => (string)$row['id'],
'datum' => (string)$row['datum'],
'betrag' => round(fscash_signed_betrag((float)$row['betrag']), 2),
'waehrung' => 'EUR',
'zweck' => (string)$row['zweck'],
];
if (!empty($row['kategorie'])) {
$buchung['kategorie'] = (string)$row['kategorie'];
}
if (!empty($row['konto'])) {
$buchung['konto'] = (string)$row['konto'];
}
return $buchung;
}

View file

@ -330,4 +330,254 @@ final class FscashTest extends TestCase
$this->assertSame([], fscash_pending_migrations($available, $applied)); $this->assertSame([], fscash_pending_migrations($available, $applied));
} }
// --- fscash_parse_basic_auth_header --------------------------------
public function testParseBasicAuthHeaderMitGueltigenZugangsdaten(): void
{
$header = 'Basic ' . base64_encode('finanzmanager:geheim123');
$this->assertSame(
['username' => 'finanzmanager', 'password' => 'geheim123'],
fscash_parse_basic_auth_header($header)
);
}
public function testParseBasicAuthHeaderErlaubtDoppelpunktImPasswort(): void
{
$header = 'Basic ' . base64_encode('user:pass:mit:doppelpunkt');
$this->assertSame(
['username' => 'user', 'password' => 'pass:mit:doppelpunkt'],
fscash_parse_basic_auth_header($header)
);
}
public function testParseBasicAuthHeaderOhneHeaderLiefertNull(): void
{
$this->assertNull(fscash_parse_basic_auth_header(null));
}
public function testParseBasicAuthHeaderOhneBasicSchemaLiefertNull(): void
{
$this->assertNull(fscash_parse_basic_auth_header('Bearer irgendwas'));
}
public function testParseBasicAuthHeaderMitUngueltigemBase64LiefertNull(): void
{
$this->assertNull(fscash_parse_basic_auth_header('Basic ###nicht-base64###'));
}
public function testParseBasicAuthHeaderOhneDoppelpunktLiefertNull(): void
{
$this->assertNull(fscash_parse_basic_auth_header('Basic ' . base64_encode('ohnedoppelpunkt')));
}
// --- fscash_is_valid_date -------------------------------------------
public function testIsValidDateMitGueltigemDatum(): void
{
$this->assertTrue(fscash_is_valid_date('2026-08-19'));
$this->assertTrue(fscash_is_valid_date('2024-02-29'));
}
public function testIsValidDateMitUngueltigemDatumOderFormat(): void
{
$this->assertFalse(fscash_is_valid_date('2026-02-30'));
$this->assertFalse(fscash_is_valid_date('2023-02-29'));
$this->assertFalse(fscash_is_valid_date('19.08.2026'));
$this->assertFalse(fscash_is_valid_date('2026-8-19'));
$this->assertFalse(fscash_is_valid_date(''));
}
// --- fscash_validate_buchungen_query ---------------------------------
public function testValidateBuchungenQueryMitPflichtfeldern(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => '-12.50',
]);
$this->assertNull($result['error']);
$this->assertSame('2026-08-09', $result['von']);
$this->assertSame('2026-08-19', $result['bis']);
$this->assertSame(-12.50, $result['betrag']);
$this->assertSame(0.0, $result['toleranz']);
$this->assertSame('EUR', $result['waehrung']);
$this->assertSame(20, $result['limit']);
}
public function testValidateBuchungenQueryMitAllenOptionalenParametern(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => '-12.50',
'toleranz' => '0.50',
'waehrung' => 'eur',
'limit' => '5',
]);
$this->assertNull($result['error']);
$this->assertSame(0.50, $result['toleranz']);
$this->assertSame('EUR', $result['waehrung']);
$this->assertSame(5, $result['limit']);
}
public function testValidateBuchungenQueryOhneVonLiefertFehler(): void
{
$result = fscash_validate_buchungen_query(['bis' => '2026-08-19', 'betrag' => '-12.50']);
$this->assertSame("Parameter 'von' fehlt.", $result['error']);
}
public function testValidateBuchungenQueryOhneBisLiefertFehler(): void
{
$result = fscash_validate_buchungen_query(['von' => '2026-08-09', 'betrag' => '-12.50']);
$this->assertSame("Parameter 'bis' fehlt.", $result['error']);
}
public function testValidateBuchungenQueryMitUngueltigemDatumLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-32',
'bis' => '2026-08-19',
'betrag' => '-12.50',
]);
$this->assertSame("Parameter 'von' ist unbrauchbar.", $result['error']);
}
public function testValidateBuchungenQueryMitVonNachBisLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-19',
'bis' => '2026-08-09',
'betrag' => '-12.50',
]);
$this->assertSame("Parameter 'von' darf nicht nach 'bis' liegen.", $result['error']);
}
public function testValidateBuchungenQueryOhneBetragLiefertFehler(): void
{
$result = fscash_validate_buchungen_query(['von' => '2026-08-09', 'bis' => '2026-08-19']);
$this->assertSame("Parameter 'betrag' fehlt.", $result['error']);
}
public function testValidateBuchungenQueryMitUnbrauchbaremBetragLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => 'zwölffünfzig',
]);
$this->assertSame("Parameter 'betrag' ist unbrauchbar.", $result['error']);
}
public function testValidateBuchungenQueryMitNegativerToleranzLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => '-12.50',
'toleranz' => '-1',
]);
$this->assertSame("Parameter 'toleranz' ist unbrauchbar.", $result['error']);
}
public function testValidateBuchungenQueryMitUngueltigerWaehrungLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => '-12.50',
'waehrung' => 'EURO',
]);
$this->assertSame("Parameter 'waehrung' ist unbrauchbar.", $result['error']);
}
public function testValidateBuchungenQueryMitUngueltigemLimitLiefertFehler(): void
{
$result = fscash_validate_buchungen_query([
'von' => '2026-08-09',
'bis' => '2026-08-19',
'betrag' => '-12.50',
'limit' => '0',
]);
$this->assertSame("Parameter 'limit' ist unbrauchbar.", $result['error']);
}
// --- fscash_signed_betrag ---------------------------------------------
public function testSignedBetragDrehtVorzeichenVonGespeicherterAusgabe(): void
{
$this->assertSame(-12.50, fscash_signed_betrag(12.50));
$this->assertSame(0.0, fscash_signed_betrag(0.0));
}
// --- fscash_format_buchung ---------------------------------------------
public function testFormatBuchungMitAllenFeldern(): void
{
$row = [
'id' => 42,
'datum' => '2026-08-14',
'betrag' => 12.50,
'zweck' => 'Wocheneinkauf',
'kategorie' => 'Lebensmittel',
'konto' => 'Portemonnaie',
];
$this->assertSame(
[
'id' => '42',
'datum' => '2026-08-14',
'betrag' => -12.50,
'waehrung' => 'EUR',
'zweck' => 'Wocheneinkauf',
'kategorie' => 'Lebensmittel',
'konto' => 'Portemonnaie',
],
fscash_format_buchung($row)
);
}
public function testFormatBuchungOhneKategorieUndKontoLaesstFelderWeg(): void
{
$row = [
'id' => 42,
'datum' => '2026-08-14',
'betrag' => 12.50,
'zweck' => 'Wocheneinkauf',
'kategorie' => null,
'konto' => null,
];
$this->assertSame(
[
'id' => '42',
'datum' => '2026-08-14',
'betrag' => -12.50,
'waehrung' => 'EUR',
'zweck' => 'Wocheneinkauf',
],
fscash_format_buchung($row)
);
}
// --- fscash_api_error ---------------------------------------------------
public function testApiErrorBautFehlerobjekt(): void
{
$this->assertSame(['fehler' => 'Parameter fehlt.'], fscash_api_error('Parameter fehlt.'));
}
} }