Merge pull request 'E2E-Tests (Playwright) ergänzt – zwei Seiten mit HTTP 500 repariert, Nummernkreise werden beim Löschen freigegeben' (#168) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/168
This commit is contained in:
fds2610 2026-08-02 15:51:43 +02:00
commit 646e47a3c7
28 changed files with 1611 additions and 4 deletions

View file

@ -37,6 +37,24 @@ $this->assertGreaterThan($min, $wert, 'Beschreibung');
$this->assertNotEmpty($wert, 'Beschreibung');
```
### E2E-Tests (Browser)
Die PHP-Tests erreichen Routing, Views und Formular-JavaScript nicht. Dafür gibt
es Playwright-Tests in `tests/e2e/`, die im Container des e2e-runner laufen
(auf dem Host wird nichts installiert). Details: `tests/e2e/README.md`.
```bash
docker compose exec web php scripts/e2e-user.php # einmalig: Testnutzer anlegen
tests/e2e/run.sh # alle E2E-Tests
docker compose exec web php scripts/e2e-cleanup.php --force # Reste entfernen
```
Fehlt Docker, der Runner oder das Image, wird mit Exit 0 übersprungen.
**Welche Ebene wofür:** Rechen- und Model-Logik gehört in die PHP-Suite (schneller,
isoliert). In `tests/e2e/` gehört nur, was einen Browser braucht: Formularabläufe,
Redirects, gerenderte Views, JavaScript-Umrechnungen, DOM-XSS.
## Dokumentation aktuell halten
| Datei | Wann aktualisieren |

View file

@ -2,6 +2,25 @@
---
## 2026-08-02 – Nummernkreise geben die zuletzt vergebene Nummer beim Löschen wieder frei
- Der E2E-Finalisierungstest verbrauchte bei jedem Lauf eine Rechnungs- und eine Belegnummer: Die Testrechnung wurde zwar wieder gelöscht, die Nummern blieben aber vergeben und rissen dauerhaft Lücken in den laufenden Nummernkreis.
- **`src/Models/Invoice.php`, `src/Models/Beleg.php`:** `delete()` gibt die Nummer des gelöschten Datensatzes jetzt wieder frei. Die zuletzt vergebene Nummer wird dafür aus dem Zählerstand rekonstruiert und nur bei exakter Übereinstimmung zurückgezählt – existiert bereits eine jüngere Rechnung bzw. ein jüngerer Beleg (oder stammt die Nummer aus einem früheren Monat/Jahr), bleibt der Zähler stehen. Damit kann keine Nummer doppelt vergeben werden. `Invoice::delete()` gibt zusätzlich die Belegnummer des mitgelöschten Rechnungsbelegs frei.
- **`scripts/e2e-cleanup.php`:** Räumt liegengebliebene Testdaten absteigend ab (jüngste zuerst), damit die Freigabe für die ganze Kette greift und nicht nur für den letzten Datensatz.
- **`tests/78_NummernkreisFreigabeTest.php`** (neu): Sichert Freigabe und Wiedervergabe für beide Nummernkreise ab – inklusive der Gegenprobe, dass das Löschen eines älteren Datensatzes den Zähler unverändert lässt.
- **`tests/e2e/specs/rechnung-export.spec.js`:** Neuer Test `Löschen einer finalisierten Rechnung gibt die Nummer wieder frei` weist über zwei aufeinanderfolgende Finalisierungen nach, dass der Testlauf den Nummernkreis nicht mehr weiterschiebt. Der Hinweis auf die „nicht umkehrbare Nebenwirkung" in `tests/e2e/README.md` entfällt damit.
## 2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert
- Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen [e2e-runner](../e2e-runner/) angebunden; auf dem Host wird dafür nichts installiert.
- **`src/Controllers/AccountController.php`:** `/accounts/new` lieferte HTTP 500. `create()` las `$_GET['from']` ohne Existenzprüfung – beim Aufruf aus dem Menü (ohne Parameter) also immer. Jahrelang war das eine stille PHP-Warning; seit der Fehler-Härtung (#142) wandelt der `set_error_handler` Warnings in Exceptions um, seitdem war die Seite tot. Jetzt mit `?? ''` abgesichert.
- **`src/Controllers/AssetController.php`, `src/Views/assets/form.php`:** `/assets/new` lieferte aus demselben Grund HTTP 500. Die View nutzt `$testMode` und `$defaultKontoId` unterhalb des Bearbeiten-Zweigs, gesetzt wurden beide aber nur innerhalb davon. `create()` übergibt jetzt `testMode`, die View initialisiert beide Werte am Kopf.
- **`tests/e2e/`** (neu): 44 Playwright-Tests. `smoke.spec.js` prüft, dass alle Hauptseiten ohne Serverfehler rendern (dieser Lauf fand die beiden 500er), dazu 404-Verhalten, Login-Zwang und CSV-Export. `beleg-buchung.spec.js` deckt Upload → Buchungsmaske → gespeicherte Buchung ab, insbesondere den Netto/Brutto-Umschalter (#137), dessen Umrechnung erst im submit-Handler passiert und serverseitig nicht prüfbar ist. `rechnung-export.spec.js` prüft Entwurf → Finalisieren → PDF- und XML-Export. `bank-import.spec.js` sichert die Vorschau samt XSS-Regression (#133) ab, die vollständig im Browser aufgebaut wird.
- **`scripts/e2e-user.php`, `scripts/e2e-cleanup.php`** (neu): Legen den Testnutzer idempotent an bzw. entfernen liegengebliebene, mit `E2E-TEST` markierte Testdaten. Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten und rühren ausschließlich ihre eigenen Datensätze an.
- **`tests/77_BelegLoeschsperreTest.php`** (neu): Sichert ab, dass `Beleg::delete()` einen Beleg mit Buchungen und einen festgeschriebenen Beleg verweigert und dabei die Buchungen unangetastet lässt. Bewusst als PHP-Test – reine Model-Logik gehört nicht in den Browser.
- **`tests/67_BelegXmlXsltXssTest.php`:** Der Test legte ein Belegverzeichnis an und löschte am Ende nur die Datei, nicht das Verzeichnis. Die Transaktion rollt die Belegnummer zurück, das Dateisystem aber nicht – und weil die Suite über `docker compose exec` als *root* läuft, blieb ein root-eigenes Verzeichnis zurück. Bekam später ein echter Upload dieselbe Belegnummer, scheiterte `move_uploaded_file()` mit „Permission denied" und die Seite antwortete mit HTTP 500. Das traf nicht nur Tests, sondern den normalen Beleg-Upload nach jedem lokalen Testlauf. Der Test räumt das Verzeichnis jetzt mit ab.
- Fehlt Docker, das Runner-Projekt oder das Playwright-Image, überspringt `tests/e2e/run.sh` mit Exit 0 und einer Warnung. Die E2E-Tests bleiben damit optional und blockieren keine Umgebung ohne Runner.
## 2026-08-01 – Belege blieben nach dem Umzug nach data/ unauffindbar
- Beim Umzug der Nutzdaten nach `data/` (#150) wurden die Belege mit `mv uploads/2026 data/belege/` verschoben. Existierte im Ziel bereits ein Jahresverzeichnis – etwa weil vorher schon ein Container gelaufen war –, scheiterte `mv` (bzw. legte den Baum als `data/belege/2026/2026` ab). Weil alle Fehler mit `2>/dev/null || true` unterdrückt wurden, meldete die Migration trotzdem Erfolg: In der Oberfläche waren die Belege danach nicht mehr auffindbar, obwohl die Datenbankeinträge unverändert waren.

View file

@ -348,6 +348,21 @@ Anschließend im Browser unter `http://localhost:8080` (oder dem konfigurierten
| [Datenbankstruktur](docs/datenbankstruktur.md) | Tabellen, Spalten, Fremdschlüssel, GoBD-Mechanismen |
| [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln |
| [Kontenrahmen](docs/kontenrahmen.md) | Vollständige SKR03-Kontenliste |
| [E2E-Tests](tests/e2e/README.md) | Browser-Tests mit Playwright über den e2e-runner |
---
## Tests
```bash
docker compose exec web php tests/run.php # PHP-Suite (Models, Services, Berechnungen)
tests/e2e/run.sh # Browser-Tests (Routing, Views, Formular-JS)
```
Die PHP-Suite läuft ohne weitere Voraussetzungen. Die E2E-Tests brauchen Docker,
das [e2e-runner-Projekt](../e2e-runner/) und einen Testnutzer
(`docker compose exec web php scripts/e2e-user.php`); fehlt etwas davon, werden
sie mit einer Warnung übersprungen. Details in [tests/e2e/README.md](tests/e2e/README.md).
---

View file

@ -52,6 +52,7 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
- DIN 5008-konformes Layout, PDF-Export via mPDF
- Rechnungsnummernformat **PPPYYYYMM-nn** (konfigurierbarer Präfix + Jahr + Monat + laufende Nummer, monatlicher Reset)
- **Nummernfreigabe beim Löschen**: Wird eine Rechnung gelöscht (im Testmodus) und trug sie die zuletzt vergebene Nummer, zählt der Nummernkreis zurück – die Nummer wird als nächste wieder vergeben, statt eine Lücke zu hinterlassen. Für die Belegnummer des zugehörigen Belegs gilt dasselbe. War die Nummer nicht die letzte, bleibt der Zähler stehen; eine Nummer wird nie doppelt vergeben.
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.

108
scripts/e2e-cleanup.php Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Entfernt liegengebliebene E2E-Testdaten.
*
* Die E2E-Tests räumen ihre Datensätze selbst wieder ab. Bricht ein Lauf
* vorzeitig ab, können markierte Reste zurückbleiben — die holt dieses Skript.
* Erkannt wird ausschließlich der Marker "E2E-TEST" (siehe tests/e2e/helpers/app.js);
* echte Buchführungsdaten bleiben unangetastet.
*
* Verwendung:
* docker compose exec web php scripts/e2e-cleanup.php # nur anzeigen
* docker compose exec web php scripts/e2e-cleanup.php --force # wirklich löschen
*/
$projectRoot = dirname(__DIR__);
if (!file_exists($projectRoot . '/vendor/autoload.php')) {
fwrite(STDERR, "Fehler: vendor/autoload.php nicht gefunden. Bitte 'composer install' ausführen.\n");
exit(1);
}
require $projectRoot . '/vendor/autoload.php';
\Steuerkram\Config::load();
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Setting;
const MARKER = 'E2E-TEST%';
$force = in_array('--force', array_slice($argv, 1), true);
if (!Setting::isTestMode()) {
fwrite(STDERR, "Abbruch: Die Instanz läuft nicht im Testmodus – hier wird nichts gelöscht.\n");
exit(1);
}
// Absteigend (jüngste zuerst): Nur die zuletzt vergebene Nummer wandert beim
// Löschen in den Nummernkreis zurück – rückwärts abgeräumt greift das für die
// ganze Kette statt nur für den letzten Datensatz.
$belege = Database::fetchAll(
'SELECT id, beleg_nr, filename FROM belege WHERE notes LIKE ? OR filename LIKE ? ORDER BY id DESC',
[MARKER, MARKER]
);
$rechnungen = Database::fetchAll(
'SELECT id, rechnungs_nr, betreff, status FROM invoices WHERE betreff LIKE ? ORDER BY id DESC',
[MARKER]
);
if (!$belege && !$rechnungen) {
echo "Keine E2E-Testdaten gefunden.\n";
exit(0);
}
echo 'Gefunden: ' . count($belege) . ' Beleg(e), ' . count($rechnungen) . " Rechnung(en).\n";
foreach ($belege as $b) {
echo " Beleg #{$b['id']} {$b['beleg_nr']} ({$b['filename']})\n";
}
foreach ($rechnungen as $r) {
$nr = $r['rechnungs_nr'] ?: 'Entwurf';
echo " Rechnung #{$r['id']} {$nr} [{$r['status']}] {$r['betreff']}\n";
}
if (!$force) {
echo "\nNichts gelöscht. Zum Löschen erneut mit --force aufrufen.\n";
exit(0);
}
$geloescht = ['belege' => 0, 'rechnungen' => 0];
$fehler = [];
foreach ($rechnungen as $r) {
try {
Invoice::delete((int)$r['id']);
$geloescht['rechnungen']++;
} catch (\Throwable $e) {
$fehler[] = "Rechnung #{$r['id']}: " . $e->getMessage();
}
}
foreach ($belege as $b) {
$id = (int)$b['id'];
// Buchungen zuerst – sonst verweigert Beleg::delete() die Löschung.
Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$id]);
Database::execute('UPDATE belege SET locked = 0 WHERE id = ?', [$id]);
if (Beleg::delete($id)) {
$geloescht['belege']++;
} else {
$fehler[] = "Beleg #{$id} konnte nicht gelöscht werden.";
}
}
echo "\nGelöscht: {$geloescht['belege']} Beleg(e), {$geloescht['rechnungen']} Rechnung(en).\n";
if ($fehler) {
echo "Nicht gelöscht:\n";
foreach ($fehler as $f) {
echo " - {$f}\n";
}
exit(1);
}

84
scripts/e2e-user.php Normal file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Legt den Testnutzer für die E2E-Tests an (idempotent).
*
* Die E2E-Tests (tests/e2e/) melden sich über das Login-Formular an und
* brauchen dafür einen eigenen Account – nicht den Arbeits-Account, dessen
* Passwort hier sonst überschrieben würde.
*
* Der Aufruf ist wiederholbar: Existiert der Nutzer schon, werden nur Passwort
* und Status zurückgesetzt (aktiv, keine Fehlversuche, nicht gesperrt).
*
* Verwendung:
* docker compose exec web php scripts/e2e-user.php
* docker compose exec web php scripts/e2e-user.php <username> <passwort>
* docker compose exec web php scripts/e2e-user.php --delete
*
* Die Defaults entsprechen tests/e2e/helpers/env.js.
*/
$projectRoot = dirname(__DIR__);
if (!file_exists($projectRoot . '/vendor/autoload.php')) {
fwrite(STDERR, "Fehler: vendor/autoload.php nicht gefunden. Bitte 'composer install' ausführen.\n");
exit(1);
}
require $projectRoot . '/vendor/autoload.php';
\Steuerkram\Config::load();
use Steuerkram\Database;
use Steuerkram\Models\User;
const E2E_DEFAULT_USER = 'e2e';
const E2E_DEFAULT_PASSWORD = 'e2e-test-Pass123!';
$args = array_slice($argv, 1);
$delete = in_array('--delete', $args, true);
$args = array_values(array_filter($args, static fn(string $a): bool => $a !== '--delete'));
$username = $args[0] ?? E2E_DEFAULT_USER;
$password = $args[1] ?? E2E_DEFAULT_PASSWORD;
$existing = User::findByUsername($username);
if ($delete) {
if (!$existing) {
echo "Testnutzer \"{$username}\" existiert nicht – nichts zu tun.\n";
exit(0);
}
User::delete((int)$existing['id']);
echo "Testnutzer \"{$username}\" gelöscht.\n";
exit(0);
}
if ($existing) {
$id = (int)$existing['id'];
User::update($id, $username, $password);
User::setActive($id, true);
User::resetFailedLogins($id);
echo "Testnutzer \"{$username}\" aktualisiert (Passwort neu gesetzt, aktiv, entsperrt).\n";
} else {
User::create($username, $password);
$created = User::findByUsername($username);
if (!$created) {
fwrite(STDERR, "Fehler: Testnutzer \"{$username}\" konnte nicht angelegt werden.\n");
exit(1);
}
echo "Testnutzer \"{$username}\" angelegt.\n";
}
// Gegenprobe: Das Passwort muss zum gespeicherten Hash passen, sonst läuft der
// E2E-Login in den Throttle und sperrt den Account.
$check = User::findByUsername($username);
if (!$check || !password_verify($password, $check['password_hash'])) {
fwrite(STDERR, "Fehler: Passwortprüfung für \"{$username}\" fehlgeschlagen.\n");
exit(1);
}
$anzahl = (int)(Database::fetchOne('SELECT COUNT(*) AS n FROM users')['n'] ?? 0);
echo "Benutzer in der Datenbank: {$anzahl}\n";
echo "E2E-Tests starten mit: tests/e2e/run.sh\n";

View file

@ -141,7 +141,7 @@ class AccountController extends BaseController
public function create(array $params): void
{
$back = $_GET['from'] === 'settings' ? '/settings' : '/accounts';
$back = ($_GET['from'] ?? '') === 'settings' ? '/settings' : '/accounts';
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Konto anlegen',

View file

@ -62,6 +62,7 @@ class AssetController extends BaseController
'action' => '/assets',
'anlagearten' => Asset::anlagearten(),
'scheduleRows' => [],
'testMode' => Setting::isTestMode(),
]),
]);
}

View file

@ -267,9 +267,40 @@ class Beleg
}
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
self::releaseBelegNr((string)($beleg['beleg_nr'] ?? ''));
return true;
}
/**
* Gibt die Belegnummer eines gelöschten Belegs wieder frei, sofern sie die
* zuletzt vergebene war – sonst risse jeder gelöschte Beleg eine Lücke in
* den Nummernkreis.
*
* Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert und nur bei
* exakter Übereinstimmung zurückgezählt. Existiert bereits ein jüngerer
* Beleg (oder stammt die Nummer aus einem früheren Jahr), passt die
* Rekonstruktion nicht und der Zähler bleibt stehen; eine Nummer kann
* dadurch nie doppelt vergeben werden.
*/
public static function releaseBelegNr(string $nr): void
{
if ($nr === '') return;
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
if (!$settings) return;
$next = (int)($settings['beleg_nr_next'] ?? 1);
if ($next <= 1) return;
$letzte = sprintf('%s-%s-%04d', $settings['beleg_nr_prefix'] ?? 'BLG', date('Y'), $next - 1);
if ($nr !== $letzte) return;
Database::execute(
'UPDATE company_settings SET beleg_nr_next=? WHERE id=1 AND beleg_nr_next=?',
[$next - 1, $next]
);
}
public static function generateBelegNr(): string
{
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');

View file

@ -397,6 +397,7 @@ class Invoice
if (!$invoice) return;
$belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null;
$beleg = $belegId !== null ? Beleg::find($belegId) : false;
Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]);
Database::execute('DELETE FROM invoices WHERE id = ?', [$id]);
@ -405,6 +406,48 @@ class Invoice
Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]);
Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]);
}
// Beide Nummernkreise zurückrollen, solange die gelöschten Datensätze
// die zuletzt vergebenen Nummern trugen – sonst bliebe eine Lücke.
self::releaseRechnungsNr((string)($invoice['rechnungs_nr'] ?? ''));
if ($beleg) {
Beleg::releaseBelegNr((string)($beleg['beleg_nr'] ?? ''));
}
}
/**
* Gibt die Rechnungsnummer einer gelöschten Rechnung wieder frei, sofern sie
* die zuletzt vergebene war.
*
* Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert; nur bei exakter
* Übereinstimmung wird zurückgezählt. Gibt es bereits eine jüngere Rechnung,
* passt die Rekonstruktion nicht und der Zähler bleibt stehen – eine Nummer
* kann so nie doppelt vergeben werden.
*/
private static function releaseRechnungsNr(string $nr): void
{
if ($nr === '') return;
$settings = Database::fetchOne(
'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year, invoice_nr_month FROM company_settings WHERE id=1'
);
if (!$settings) return;
$next = (int)($settings['invoice_nr_next'] ?? 1);
if ($next <= 1) return;
$letzte = self::formatRechnungsNr(
(string)($settings['invoice_nr_prefix'] ?? 'AR'),
(int)($settings['invoice_nr_year'] ?? 0),
(int)($settings['invoice_nr_month'] ?? 0),
$next - 1
);
if ($nr !== $letzte) return;
Database::execute(
'UPDATE company_settings SET invoice_nr_next=? WHERE id=1 AND invoice_nr_next=?',
[$next - 1, $next]
);
}
public static function generateRechnungsNr(): string
@ -423,8 +466,7 @@ class Invoice
$next = 1;
}
$digits = max(2, strlen((string)$next));
$nr = sprintf('%s%04d%02d-%0' . $digits . 'd', $prefix, $year, $month, $next);
$nr = self::formatRechnungsNr($prefix, $year, $month, $next);
Database::execute(
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=?, invoice_nr_month=? WHERE id=1',
@ -433,4 +475,11 @@ class Invoice
return $nr;
}
/** Einheitliches Format des Nummernkreises: Präfix + Jahr + Monat + laufende Nummer. */
private static function formatRechnungsNr(string $prefix, int $year, int $month, int $lfd): string
{
$digits = max(2, strlen((string)$lfd));
return sprintf('%s%04d%02d-%0' . $digits . 'd', $prefix, $year, $month, $lfd);
}
}

View file

@ -11,6 +11,11 @@ $centFlag = !empty($asset['cent_im_ersten_jahr']);
$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true;
$bkHints = $bookingHints ?? [];
// Beide Werte werden im Skriptteil unterhalb des Bearbeiten-Zweigs verwendet,
// gesetzt aber nur dort. Beim Anlegen müssen sie trotzdem definiert sein.
$defaultKontoId = 0;
$testMode = $testMode ?? false;
$conflictBadge = function(string $field, string $label) use ($bkHints, $h): string {
if (!isset($bkHints[$field])) return '';
$val = $h($bkHints[$field]);

View file

@ -1 +1 @@
1-260801
1-260802

View file

@ -89,6 +89,12 @@ class BelegXmlXsltXssTest extends TestCase
'Der Content-Disposition-Header nutzt die ermittelte Disposition (nicht fest inline)'
);
// Datei UND Verzeichnis entfernen: Die Transaktion rollt die Belegnummer
// zurück, das Dateisystem aber nicht. Ein zurückbleibendes Verzeichnis
// gehört – weil die Suite via `docker compose exec` als root läuft – dem
// Nutzer root und blockiert später den echten Upload mit derselben
// Nummer (move_uploaded_file: Permission denied → HTTP 500).
@unlink($absPath);
@rmdir($absDir);
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/**
* Test 77: Beleg::delete() – Löschsperre bei Buchungen und gesperrten Belegen.
*
* Ein Beleg, auf den bereits gebucht wurde, darf nicht verschwinden – sonst
* hingen die Buchungen ohne Nachweis in der Buchführung (GoBD). Dasselbe gilt
* für festgeschriebene (locked) Belege.
*
* Ergänzt Test 49, der nur den Fall "unlink() schlägt fehl" abdeckt.
*/
class BelegLoeschsperreTest extends TestCase
{
protected string $testName = 'Test 77: Beleg::delete() – Löschsperre';
public function run(): void
{
$kategorieId = $this->findAccountId('4930');
$zahlungsId = $this->findAccountId('1200');
$this->assertNotEmpty($kategorieId, 'Kategoriekonto 4930 vorhanden');
$this->assertNotEmpty($zahlungsId, 'Zahlungskonto 1200 vorhanden');
// ── Beleg ohne Buchung: löschbar ──────────────────────────────
$freierBelegId = $this->createBeleg('E2E-frei');
$this->assert(
\Steuerkram\Models\Beleg::delete($freierBelegId) === true,
'Beleg ohne Buchungen lässt sich löschen'
);
$this->assert(
\Steuerkram\Models\Beleg::find($freierBelegId) === false,
'Gelöschter Beleg ist nicht mehr auffindbar'
);
// ── Beleg mit Buchung: gesperrt ───────────────────────────────
$gebuchterBelegId = $this->createBeleg('E2E-gebucht');
\Steuerkram\Models\Booking::create([
'beleg_id' => $gebuchterBelegId,
'buchungstyp' => 'Ausgabe',
'buchungs_datum' => date('Y-m-d'),
'kategorie_konto_id' => $kategorieId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 42.00,
'ust_satz' => 19,
'buchungstext' => 'Löschsperre-Test',
]);
$anzahl = \Steuerkram\Database::fetchOne(
'SELECT COUNT(*) AS n FROM bookings WHERE beleg_id = ?',
[$gebuchterBelegId]
);
$this->assertGreaterThan(0, (int)$anzahl['n'], 'Buchung wurde angelegt');
$this->assert(
\Steuerkram\Models\Beleg::delete($gebuchterBelegId) === false,
'Beleg mit Buchungen lässt sich NICHT löschen'
);
$this->assertNotEmpty(
\Steuerkram\Models\Beleg::find($gebuchterBelegId),
'Gebuchter Beleg existiert nach dem Löschversuch weiterhin'
);
$nachher = \Steuerkram\Database::fetchOne(
'SELECT COUNT(*) AS n FROM bookings WHERE beleg_id = ?',
[$gebuchterBelegId]
);
$this->assertEquals(
(int)$anzahl['n'],
(int)$nachher['n'],
'Die Buchungen des Belegs bleiben unangetastet'
);
// ── Gesperrter Beleg ohne Buchung: ebenfalls gesperrt ─────────
$lockedBelegId = $this->createBeleg('E2E-locked');
\Steuerkram\Database::execute('UPDATE belege SET locked = 1 WHERE id = ?', [$lockedBelegId]);
$this->assert(
\Steuerkram\Models\Beleg::delete($lockedBelegId) === false,
'Festgeschriebener Beleg lässt sich NICHT löschen'
);
$this->assertNotEmpty(
\Steuerkram\Models\Beleg::find($lockedBelegId),
'Festgeschriebener Beleg existiert weiterhin'
);
}
/** Legt einen Beleg ohne Datei an (storage_path zeigt bewusst ins Leere). */
private function createBeleg(string $marker): int
{
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
return \Steuerkram\Models\Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $marker . '.pdf',
'storage_path' => date('Y') . '/' . $marker . '/original.pdf',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $belegNr . $marker),
'belegtyp' => 'Eingangsrechnung',
'beleg_datum' => date('Y-m-d'),
'notes' => $marker,
]);
}
}

View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
/**
* Test 78: Freigabe der zuletzt vergebenen Nummer beim Löschen.
*
* Wird eine Rechnung oder ein Beleg gelöscht, gibt der jeweilige Nummernkreis
* die Nummer wieder frei – aber nur, wenn es die zuletzt vergebene war. Sonst
* hinterließe jeder Löschvorgang (z.B. ein E2E-Testlauf) eine unumkehrbare
* Lücke im Nummernkreis.
*
* Gegenprobe: Wird eine ältere Nummer gelöscht, bleibt der Zähler stehen –
* andernfalls würde eine bereits vergebene Nummer ein zweites Mal ausgegeben.
*/
class NummernkreisFreigabeTest extends TestCase
{
protected string $testName = 'Test 78: Nummernkreis gibt die zuletzt vergebene Nummer beim Löschen frei';
public function run(): void
{
$this->rechnungsnummerWirdFreigegeben();
$this->aeltereRechnungsnummerBleibtVerbraucht();
$this->belegnummerWirdFreigegeben();
}
// ── Rechnungen ──────────────────────────────────────────────────────────
private function rechnungsnummerWirdFreigegeben(): void
{
$vorher = $this->invoiceNrNext();
$invoiceId = $this->createRechnung('Nummernkreis-Freigabe');
$nr = \Steuerkram\Models\Invoice::finalize($invoiceId);
$this->assertNotEmpty($nr, 'Finalisieren vergibt eine Rechnungsnummer');
$this->assertEquals($vorher + 1, $this->invoiceNrNext(), 'Zähler steht nach dem Finalisieren eins weiter');
\Steuerkram\Models\Invoice::delete($invoiceId);
$this->assertEquals(
$vorher,
$this->invoiceNrNext(),
'Nach dem Löschen steht der Zähler wieder auf dem Ausgangswert'
);
// Die freigegebene Nummer muss erneut vergeben werden – sonst entstünde
// trotz zurückgesetztem Zähler eine andere Nummer.
$zweiteId = $this->createRechnung('Nummernkreis-Wiedervergabe');
$this->assertEquals($nr, \Steuerkram\Models\Invoice::finalize($zweiteId), 'Die freigegebene Nummer wird erneut vergeben');
\Steuerkram\Models\Invoice::delete($zweiteId);
}
private function aeltereRechnungsnummerBleibtVerbraucht(): void
{
$ersteId = $this->createRechnung('Nummernkreis-Alt');
\Steuerkram\Models\Invoice::finalize($ersteId);
$zweiteId = $this->createRechnung('Nummernkreis-Neu');
$zweiteNr = \Steuerkram\Models\Invoice::finalize($zweiteId);
$standMitBeiden = $this->invoiceNrNext();
// Die ältere der beiden Rechnungen löschen: ihre Nummer darf nicht
// freigegeben werden, sie liegt vor der bereits vergebenen $zweiteNr.
\Steuerkram\Models\Invoice::delete($ersteId);
$this->assertEquals(
$standMitBeiden,
$this->invoiceNrNext(),
'Das Löschen einer älteren Rechnung lässt den Zähler unverändert'
);
// Jetzt die jüngste löschen – deren Nummer wird freigegeben.
\Steuerkram\Models\Invoice::delete($zweiteId);
$this->assertEquals(
$standMitBeiden - 1,
$this->invoiceNrNext(),
'Das Löschen der jüngsten Rechnung gibt deren Nummer frei'
);
$dritteId = $this->createRechnung('Nummernkreis-Nachrueck');
$this->assertEquals(
$zweiteNr,
\Steuerkram\Models\Invoice::finalize($dritteId),
'Die freigegebene Nummer der jüngsten Rechnung rückt nach'
);
\Steuerkram\Models\Invoice::delete($dritteId);
}
// ── Belege ──────────────────────────────────────────────────────────────
private function belegnummerWirdFreigegeben(): void
{
$vorher = $this->belegNrNext();
$ersterId = $this->createBeleg('Nummernkreis-Beleg-1');
$zweiterId = $this->createBeleg('Nummernkreis-Beleg-2');
$this->assertEquals($vorher + 2, $this->belegNrNext(), 'Zwei Belege verbrauchen zwei Nummern');
// Älterer Beleg: keine Freigabe.
$this->assert(\Steuerkram\Models\Beleg::delete($ersterId), 'Älterer Beleg ist löschbar');
$this->assertEquals(
$vorher + 2,
$this->belegNrNext(),
'Das Löschen eines älteren Belegs lässt den Zähler unverändert'
);
// Jüngster Beleg: Nummer wird frei.
$this->assert(\Steuerkram\Models\Beleg::delete($zweiterId), 'Jüngster Beleg ist löschbar');
$this->assertEquals(
$vorher + 1,
$this->belegNrNext(),
'Das Löschen des jüngsten Belegs gibt dessen Nummer frei'
);
}
// ── Hilfsfunktionen ─────────────────────────────────────────────────────
private function invoiceNrNext(): int
{
$row = \Steuerkram\Database::fetchOne('SELECT invoice_nr_next FROM company_settings WHERE id=1');
return (int)($row['invoice_nr_next'] ?? 0);
}
private function belegNrNext(): int
{
$row = \Steuerkram\Database::fetchOne('SELECT beleg_nr_next FROM company_settings WHERE id=1');
return (int)($row['beleg_nr_next'] ?? 0);
}
private function createRechnung(string $marker): int
{
$customerId = \Steuerkram\Models\Customer::create([
'nachname' => 'Nummernkreis',
'firma' => $marker . ' GmbH',
'strasse' => 'Zählerweg 1',
'plz' => '12345',
'ort' => 'Nummernstadt',
'land' => 'DE',
'payment_days' => 14,
]);
return \Steuerkram\Models\Invoice::create([
'customer_id' => $customerId,
'betreff' => $marker,
'rechnungs_datum' => date('Y-m-d'),
'lines' => [
['bezeichnung' => 'Leistung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 100.00, 'ust_satz' => 19.0],
],
]);
}
/** Legt einen Beleg ohne Datei an (storage_path zeigt bewusst ins Leere). */
private function createBeleg(string $marker): int
{
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
return \Steuerkram\Models\Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $marker . '.pdf',
'storage_path' => date('Y') . '/' . $marker . '/original.pdf',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $belegNr . $marker),
'belegtyp' => 'Eingangsrechnung',
'beleg_datum' => date('Y-m-d'),
'notes' => $marker,
]);
}
}

4
tests/e2e/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
.auth/

130
tests/e2e/README.md Normal file
View file

@ -0,0 +1,130 @@
# Steuerkram — E2E-Tests (Playwright)
Browser-Tests für die Ebene, die die PHP-Testsuite nicht erreicht: Routing,
Controller, Views und das JavaScript in den Formularen.
Sie laufen **im Container** des generischen
[e2e-runner-Projekts](../../../e2e-runner/) (`~/projects/e2e-runner/`) — auf dem
Host wird nichts installiert (kein Node, kein Browser, kein npm).
## Warum diese Ebene
Die PHP-Suite (`tests/NN_*.php`) prüft Models und Services und ist dort gründlich.
Sie ruft aber nie einen Controller über HTTP auf. Genau dort saßen die zuletzt
aufgetretenen Fehler:
- **`/accounts/new` und `/assets/new` lieferten HTTP 500** — gefunden vom ersten
Smoke-Lauf. Ursache: undefinierte Variablen, die seit der Fehler-Härtung
(`Fix #142`) keine stille Warning mehr sind, sondern eine Exception.
- **Netto/Brutto-Umschalter** (`Fix #137`): Ein submit-Handler rechnet den
eingegebenen Bruttowert vor dem Absenden in Netto um. Serverseitig ist nur das
Ergebnis sichtbar — eine kaputte Umrechnung fiele nie auf.
- **DOM-XSS in der Bankimport-Vorschau** (`Fix #133`): Die Vorschau wird
vollständig im Browser aus einer `fetch`-Antwort gebaut.
## Voraussetzungen
1. **Steuerkram läuft** unter `http://localhost:8080` (`docker compose up -d`).
2. **Testnutzer existiert** (einmalig, idempotent wiederholbar):
```bash
docker compose exec web php scripts/e2e-user.php
```
Legt `e2e` / `e2e-test-Pass123!` an bzw. setzt Passwort und Sperrstatus
zurück. Abweichende Zugangsdaten als Argumente oder per `E2E_USER` /
`E2E_PASSWORD` (Defaults: `helpers/env.js`). Entfernen mit `--delete`.
3. **Testmodus aktiv** — sonst lassen sich Testbuchungen und -rechnungen nicht
wieder löschen (Einstellungen → Testmodus).
## Ausführen
```bash
tests/e2e/run.sh
```
Das Skript prüft vorab, ob Docker, das e2e-runner-Projekt (Pfad überschreibbar
via `E2E_RUNNER_DIR`) und das Playwright-Image vorhanden sind. Fehlt etwas, wird
**nur gewarnt und übersprungen** (Exit 0) — die E2E-Tests sind optional und
blockieren keine Umgebung ohne Runner.
Andere Zielinstanz: `BASE_URL=http://host.docker.internal:9000 tests/e2e/run.sh`
Einzelne Datei oder UI-Modus zum Debuggen:
```bash
cd ~/projects/e2e-runner
TEST_DIR=~/projects/Steuerkram/tests/e2e docker compose run --rm playwright \
sh -c "npm ci && npx playwright test smoke.spec.js"
# UI-Modus, danach http://localhost:8077 öffnen:
TEST_DIR=~/projects/Steuerkram/tests/e2e docker compose run --rm --service-ports playwright-ui
```
HTML-Report, Screenshots und Traces landen in `playwright-report/` und
`test-results/`.
## Umgang mit Testdaten
Die Tests laufen gegen eine Instanz mit **echten Arbeitsdaten**. Deshalb gilt:
- Sie legen ausschließlich eigene Datensätze an, markiert mit `E2E-TEST-<zeit>-<n>`
(`helpers/app.js`, `uniqueTag`), und räumen sie im `afterEach` wieder ab.
- Bestehende Belege, Buchungen und Rechnungen werden nie verändert oder gelöscht.
- Der Bankimport-Test bricht **vor** dem Bestätigen ab — die Vorschau allein legt
keine Transaktionen an.
Reste nach einem abgebrochenen Lauf entfernen:
```bash
docker compose exec web php scripts/e2e-cleanup.php # nur anzeigen
docker compose exec web php scripts/e2e-cleanup.php --force # löschen
```
### Nummernkreise
`rechnung-export.spec.js` finalisiert eine Rechnung und vergibt dabei eine
Rechnungs- und eine Belegnummer. Beide gehen beim Aufräumen wieder zurück:
`Invoice::delete()` und `Beleg::delete()` zählen den jeweiligen Nummernkreis
zurück, wenn der gelöschte Datensatz die **zuletzt vergebene** Nummer trug.
Nach einem vollständigen Testlauf stehen die Zähler also wieder auf ihrem
Ausgangswert; der Test `Löschen einer finalisierten Rechnung gibt die Nummer
wieder frei` weist das nach.
War die Nummer nicht die letzte (z.B. weil parallel eine echte Rechnung
finalisiert wurde), bleibt der Zähler stehen — eine Nummer wird nie zweimal
vergeben. In dem Fall entsteht wie bisher eine Lücke im Nummernkreis.
Bricht ein Lauf mittendrin ab, bleiben Testdatensätze liegen;
`scripts/e2e-cleanup.php --force` räumt sie nachträglich ab und arbeitet dabei
absteigend, damit möglichst viele Nummern zurückwandern.
## Aufbau
| Datei | Inhalt |
|---|---|
| `specs/smoke.spec.js` | Alle Hauptseiten rendern ohne Serverfehler; 404, Login-Zwang, CSV-Export |
| `specs/beleg-buchung.spec.js` | Upload → Buchungsmaske → gespeicherte Buchung, inkl. Netto/Brutto-Umschalter |
| `specs/rechnung-export.spec.js` | Entwurf → Finalisieren → PDF- und XML-Export (XRechnung 3.0) |
| `specs/bank-import.spec.js` | Bankimport-Vorschau inkl. XSS-Regression |
| `helpers/app.js` | Upload, Aufräumen, Betragsparsing, `gotoOk` |
| `helpers/env.js` | Zugangsdaten und Basis-URL |
| `global-setup.js` | Einmaliger Login, legt `.auth/user.json` an |
## Fallstricke
- **Submit-Buttons immer auf ihr Formular scopen.** In der Sidebar steht das
Logout-Formular (`layout/nav.php`); ein ungescopetes
`button[type="submit"]` trifft dessen Button zuerst und beendet die Session —
alle folgenden Tests laufen dann gegen die Login-Seite.
- **Reihenfolge in der Buchungsmaske:** Die Auswahl des Kategorie-Kontos ruft
`updateAutoUst()` und setzt den USt-Satz passend zum Steuercode. Den Satz
deshalb **nach** dem Konto setzen.
- **Ein Redirect auf `/login` endet mit Status 200.** `gotoOk()` prüft deshalb
zusätzlich den Pfad — sonst schlagen Tests mit irreführenden Meldungen fehl.
- **Belegverzeichnisse aus PHP-Testläufen:** `docker compose exec web php tests/run.php`
läuft als *root* und kann in `data/belege/<Jahr>/` ein Verzeichnis anlegen, das
root gehört, während die Transaktion die Belegnummer zurückrollt. Trifft später
ein echter Upload auf dieselbe Nummer, scheitert `move_uploaded_file()` mit
HTTP 500. Solche Leichen finden:
`find data/belege -maxdepth 2 -user root`

48
tests/e2e/global-setup.js Normal file
View file

@ -0,0 +1,48 @@
// @ts-check
// Einmalig per Login-Formular anmelden und den Session-Zustand (Cookies) als
// storageState speichern — alle Tests starten damit eingeloggt.
//
// Steuerkram sperrt einen Account nach mehreren Fehlversuchen (Login-Throttle,
// siehe tests/53_LoginThrottleTest.php). Ein falsches Passwort hier würde den
// Testnutzer also sperren – deshalb bricht das Setup mit einer klaren Meldung ab,
// statt es erneut zu versuchen.
const { chromium } = require('@playwright/test')
const { BASE_URL, E2E_USER, E2E_PASSWORD } = require('./helpers/env')
module.exports = async () => {
const browser = await chromium.launch()
const page = await browser.newPage({ baseURL: BASE_URL })
await page.goto('/login')
// Ist noch kein Benutzer angelegt, zeigt Steuerkram die Ersteinrichtung.
// Dann liefe der Login ins Leere – lieber sofort verständlich abbrechen.
if (await page.locator('#password2').count() > 0) {
await browser.close()
throw new Error(
`Die Instanz unter ${BASE_URL} ist im Ersteinrichtungs-Modus (kein Benutzer vorhanden). ` +
'Bitte zuerst einen Benutzer anlegen, dann scripts/e2e-user.php ausführen.'
)
}
await page.fill('#username', E2E_USER)
await page.fill('#password', E2E_PASSWORD)
await page.click('form[action="/login"] button[type="submit"]')
// Erfolgreicher Login = Redirect weg von /login (Dashboard).
try {
await page.waitForURL(url => !url.pathname.startsWith('/login'), { timeout: 15000 })
} catch {
const fehler = await page.locator('.flash-error').first().textContent().catch(() => null)
await browser.close()
throw new Error(
`Login als "${E2E_USER}" auf ${BASE_URL} fehlgeschlagen` +
(fehler ? `: ${fehler.trim()}` : '.') +
'\nTestnutzer anlegen: docker compose exec web php scripts/e2e-user.php' +
'\n(Abweichende Zugangsdaten via E2E_USER / E2E_PASSWORD.)'
)
}
await page.context().storageState({ path: '.auth/user.json' })
await browser.close()
}

182
tests/e2e/helpers/app.js Normal file
View file

@ -0,0 +1,182 @@
// @ts-check
// Gemeinsame Hilfsfunktionen für die Steuerkram-E2E-Tests.
//
// Grundsatz: Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten.
// Sie legen deshalb ausschließlich eigene, eindeutig markierte Datensätze an
// (siehe uniqueTag) und räumen diese im Teardown wieder ab. Bestehende Daten
// werden nie verändert oder gelöscht.
const { expect } = require('@playwright/test')
/** Präfix, an dem sich Testdaten jederzeit erkennen und aufräumen lassen. */
const TAG_PREFIX = 'E2E-TEST'
let counter = 0
/** Eindeutiger Marker pro Datensatz, z.B. "E2E-TEST-1754130000000-3". */
function uniqueTag(suffix = '') {
counter += 1
return `${TAG_PREFIX}-${Date.now()}-${counter}${suffix ? '-' + suffix : ''}`
}
/**
* Minimales, gültiges PDF. Der Marker landet im Dateiinhalt, damit jeder
* Upload einen eigenen SHA-256-Hash hat — sonst greift die Dublettenprüfung.
*/
function pdfBuffer(marker) {
const pdf = [
'%PDF-1.4',
'1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj',
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj',
'3 0 obj<</Type/Page/MediaBox[0 0 300 200]/Parent 2 0 R>>endobj',
`% ${marker}`,
'trailer<</Root 1 0 R>>',
'%%EOF',
].join('\n')
return Buffer.from(pdf, 'latin1')
}
/**
* Prüft, dass keine generische 500-Seite ausgeliefert wurde.
* public/index.php zeigt bei unbehandelten Fehlern genau diesen Text —
* ein reiner Statuscode-Check würde ihn nicht zuverlässig erkennen.
*/
async function expectNoServerError(page) {
await expect(page.locator('body')).not.toContainText('Ein Fehler ist aufgetreten')
}
/**
* Öffnet einen Pfad und stellt sicher, dass er ohne Serverfehler rendert.
*
* Wichtig: Eine abgelaufene Session leitet auf /login um, was mit Status 200
* endet. Ohne die URL-Prüfung liefe jeder Test danach gegen die Login-Seite und
* schlüge mit einer irreführenden Meldung fehl.
*/
async function gotoOk(page, path) {
const response = await page.goto(path)
expect(response, `Keine Antwort für ${path}`).not.toBeNull()
expect(response.status(), `Unerwarteter Status für ${path}`).toBeLessThan(400)
expect(
new URL(page.url()).pathname,
`Statt ${path} kam die Login-Seite — die Session ist nicht (mehr) gültig.`
).not.toBe('/login')
await expectNoServerError(page)
return response
}
/** window.confirm() automatisch bestätigen (Löschdialoge, siehe app.js). */
function acceptDialogs(page) {
page.on('dialog', dialog => dialog.accept().catch(() => {}))
}
/**
* Lädt einen Beleg hoch und liefert dessen ID.
* Nach dem Upload landet man auf der Detailseite /belege/{id}.
*/
async function uploadBeleg(page, { marker, belegtyp = 'Eingangsrechnung', datum = null, rechnungsNr = null } = {}) {
const tag = marker || uniqueTag()
await gotoOk(page, '/belege/upload')
await page.setInputFiles('#beleg_file', {
name: `${tag}.pdf`,
mimeType: 'application/pdf',
buffer: pdfBuffer(tag),
})
await page.selectOption('#belegtyp', belegtyp)
if (datum) await page.fill('#beleg_datum', datum)
await page.fill('#rechnungs_nr', rechnungsNr || tag)
await page.fill('#notes', tag)
// Der Klick muss auf das Upload-Formular gescopet sein: In der Sidebar steht
// das Logout-Formular (layout/nav.php) und dessen Submit-Button käme im DOM
// zuerst — ein ungescopeter Treffer würde die Session beenden.
await Promise.all([
page.waitForURL(/\/belege\/\d+/),
page.click('form[action="/belege/upload"] button[type="submit"]'),
])
const id = belegIdFromUrl(page.url())
expect(id, `Beleg-ID nicht aus URL ermittelbar: ${page.url()}`).toBeGreaterThan(0)
return { id, tag }
}
function belegIdFromUrl(url) {
const m = url.match(/\/belege\/(\d+)/)
return m ? Number(m[1]) : 0
}
/**
* Räumt einen Beleg ab: erst dessen Buchungen (nur im Testmodus löschbar),
* dann den Beleg selbst. Fehler werden geschluckt — der Teardown darf einen
* bereits gescheiterten Test nicht zusätzlich rot färben.
*/
async function cleanupBeleg(page, belegId) {
if (!belegId) return
acceptDialogs(page)
try {
await page.goto(`/belege/${belegId}`)
// Buchungen des Belegs entfernen (Formulare mit action /bookings/{id}/delete).
for (;;) {
const form = page.locator('form[action^="/bookings/"][action$="/delete"]').first()
if (await form.count() === 0) break
await form.locator('button[type="submit"]').click()
await page.waitForLoadState('networkidle')
if (!page.url().includes(`/belege/${belegId}`)) {
await page.goto(`/belege/${belegId}`)
}
}
const delForm = page.locator(`form[action="/belege/${belegId}/delete"]`).first()
if (await delForm.count() > 0) {
await delForm.locator('button[type="submit"]').click()
await page.waitForLoadState('networkidle')
}
} catch {
// Aufräumen ist best effort.
}
}
/**
* Löscht eine Rechnung (nur im Testmodus erlaubt).
*
* Das Lösch-Formular steht in der Listenansicht (invoices/list.php), nicht in
* der Detailansicht — der Aufräumschritt muss deshalb über /invoices gehen.
*/
async function cleanupInvoice(page, invoiceId) {
if (!invoiceId) return
acceptDialogs(page)
try {
await page.goto('/invoices')
const form = page.locator(`form[action="/invoices/${invoiceId}/delete"]`).first()
if (await form.count() > 0) {
await form.locator('button[type="submit"]').click()
await page.waitForLoadState('networkidle')
}
} catch {
// Aufräumen ist best effort.
}
}
/** Betrag "1.234,56 €" → 1234.56 */
function parseMoney(text) {
if (!text) return NaN
const m = String(text).match(/-?[\d.]+,\d{2}/)
if (!m) return NaN
return Number(m[0].replace(/\./g, '').replace(',', '.'))
}
module.exports = {
TAG_PREFIX,
uniqueTag,
pdfBuffer,
expectNoServerError,
gotoOk,
acceptDialogs,
uploadBeleg,
belegIdFromUrl,
cleanupBeleg,
cleanupInvoice,
parseMoney,
}

8
tests/e2e/helpers/env.js Normal file
View file

@ -0,0 +1,8 @@
// @ts-check
// Zentrale Umgebungs-Defaults. Der Testnutzer muss in der Zielinstanz existieren —
// Anlage siehe README.md in diesem Verzeichnis (scripts/e2e-user.php).
module.exports = {
BASE_URL: process.env.BASE_URL || 'http://host.docker.internal:8080',
E2E_USER: process.env.E2E_USER || 'e2e',
E2E_PASSWORD: process.env.E2E_PASSWORD || 'e2e-test-Pass123!',
}

76
tests/e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,76 @@
{
"name": "steuerkram-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "steuerkram-e2e",
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

8
tests/e2e/package.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "steuerkram-e2e",
"private": true,
"description": "E2E-Tests für Steuerkram — laufen im e2e-runner-Container (Playwright-Version muss zum Image-Tag passen)",
"devDependencies": {
"@playwright/test": "1.61.1"
}
}

View file

@ -0,0 +1,21 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test')
module.exports = defineConfig({
testDir: './specs',
globalSetup: require.resolve('./global-setup'),
// Die Tests mutieren dieselbe Steuerkram-Instanz → seriell statt parallel.
workers: 1,
// Kein retry: Ein flackernder Test soll auffallen, nicht weggeglättet werden.
retries: 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.BASE_URL || 'http://host.docker.internal:8080',
storageState: '.auth/user.json',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
})

32
tests/e2e/run.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/sh
# E2E-Tests über den generischen e2e-runner ausführen.
# Die Tests sind OPTIONAL: Fehlt die Infrastruktur (Docker, Runner-Projekt,
# gepulltes Playwright-Image), wird nur eine Warnung ausgegeben und mit
# Exit-Code 0 übersprungen — es wird nichts nachinstalliert oder gepullt.
set -eu
TEST_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
RUNNER_DIR=${E2E_RUNNER_DIR:-$HOME/projects/e2e-runner}
BASE_URL=${BASE_URL:-http://host.docker.internal:8080}
skip() {
echo "WARNUNG: $1 — E2E-Tests werden übersprungen." >&2
exit 0
}
command -v docker >/dev/null 2>&1 || skip "Docker nicht gefunden"
docker info >/dev/null 2>&1 || skip "Docker-Daemon nicht erreichbar"
[ -f "$RUNNER_DIR/docker-compose.yml" ] || skip "e2e-runner-Projekt nicht gefunden ($RUNNER_DIR)"
IMAGE=$(sed -n 's/^ *image: *//p' "$RUNNER_DIR/docker-compose.yml" | head -n1)
[ -n "$IMAGE" ] || skip "Playwright-Image im Compose-File nicht ermittelbar"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| skip "Playwright-Image $IMAGE nicht vorhanden (docker pull $IMAGE)"
# Zugangsdaten des Testnutzers durchreichen. Der Runner ist projekt-agnostisch
# und kennt nur BASE_URL – die Defaults stehen in helpers/env.js.
cd "$RUNNER_DIR"
TEST_DIR="$TEST_DIR" BASE_URL="$BASE_URL" exec docker compose run --rm \
${E2E_USER:+-e E2E_USER="$E2E_USER"} \
${E2E_PASSWORD:+-e E2E_PASSWORD="$E2E_PASSWORD"} \
playwright

View file

@ -0,0 +1,106 @@
// @ts-check
// Hotspot: Bankimport-Vorschau.
//
// Die Vorschau wird vollständig im Browser aufgebaut: Die Datei geht per fetch()
// an /bank/detect-format, das Ergebnis wird per innerHTML in die Seite geschrieben
// (src/Views/bank/upload.php). Genau hier saß das DOM-XSS aus Issue #133 — ein
// serverseitiger Test kann diesen Pfad grundsätzlich nicht erreichen.
//
// Diese Tests brechen bewusst VOR dem Bestätigen ab: Die Vorschau allein legt
// keine Banktransaktionen an, der Datenbestand bleibt unangetastet.
const { test, expect } = require('@playwright/test')
const { uniqueTag, gotoOk } = require('../helpers/app')
/** CSV im Comdirect-Format: 4 zu überspringende Zeilen, dann Header, dann Daten. */
function comdirectCsv(buchungstext, betrag = '-119,00') {
return [
'Umsätze Girokonto;',
'"Zeitraum:";"30 Tage";',
'"Neuer Kontostand";"1.234,56 EUR";',
'',
'"Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR";',
`"15.05.2026";"15.05.2026";"Lastschrift";"${buchungstext}";"${betrag}";`,
'"14.05.2026";"14.05.2026";"Gutschrift";"Zahlungseingang Kunde";"500,00";',
'',
].join('\n')
}
/** Datei in die Upload-Fläche legen und auf die fertige Formaterkennung warten. */
async function datenVorschauLaden(page, csvInhalt, dateiname = 'e2e-bank.csv') {
await gotoOk(page, '/bank/upload')
await page.setInputFiles('#bank-dnd-input', {
name: dateiname,
mimeType: 'text/csv',
buffer: Buffer.from(csvInhalt, 'utf-8'),
})
// Die Vorschau erscheint erst, wenn der fetch auf /bank/detect-format durch ist.
await expect(page.locator('#preview-firstrow')).toBeVisible({ timeout: 15000 })
}
test.describe('Bankimport-Vorschau', () => {
test('Vorschau zeigt die erste Datenzeile und aktiviert den Import-Button', async ({ page }) => {
const tag = uniqueTag()
await datenVorschauLaden(page, comdirectCsv(`Lieferant ${tag}`))
await expect(page.locator('#preview-firstrow')).toContainText(tag)
await expect(page.locator('#preview-firstrow')).toContainText('15.05.2026')
await expect(page.locator('#map-wert')).toContainText('-119,00')
// Erkanntes Format muss gesetzt und der Import damit möglich sein.
await expect(page.locator('#bank-upload-btn')).toBeEnabled()
expect(await page.locator('#format_id').inputValue()).not.toBe('')
})
test('Skriptcode im Buchungstext wird nicht ausgeführt (Regression #133)', async ({ page }) => {
const marker = uniqueTag('XSS')
// Zwei Vektoren: ein Element mit Error-Handler und ein direktes Script-Tag.
const payload = `<img src=x onerror="window.__e2eXss=true"><script>window.__e2eXss=true<\/script>${marker}`
/** @type {string[]} */
const dialoge = []
page.on('dialog', async d => { dialoge.push(d.message()); await d.dismiss() })
await datenVorschauLaden(page, comdirectCsv(payload))
// 1. Kein Skript darf gelaufen sein.
expect(await page.evaluate(() => window.__e2eXss === true), 'Injizierter Code wurde ausgeführt').toBe(false)
expect(dialoge, 'Unerwarteter Dialog aus injiziertem Code').toEqual([])
// 2. Der Payload darf nicht als DOM-Element gelandet sein.
expect(await page.locator('#preview-firstrow img').count(), 'Payload wurde als Element geparst').toBe(0)
expect(await page.locator('#preview-firstrow script').count()).toBe(0)
// 3. Er muss als sichtbarer Text erscheinen — escaped, nicht verschluckt.
await expect(page.locator('#preview-firstrow')).toContainText(marker)
await expect(page.locator('#preview-firstrow')).toContainText('<img')
})
test('Skriptcode im Mapping-Feld Buchungstext bleibt Text', async ({ page }) => {
const marker = uniqueTag('MAP')
const payload = `<b>${marker}</b>`
await datenVorschauLaden(page, comdirectCsv(payload))
// #map-buchungstext wird per textContent gesetzt — kein <b> im DOM.
await expect(page.locator('#map-buchungstext')).toContainText(marker)
expect(await page.locator('#map-buchungstext b').count(), 'Markup wurde interpretiert').toBe(0)
await expect(page.locator('#map-buchungstext')).toContainText('<b>')
})
test('Unstrukturierte Datei bringt die Vorschau nicht zum Absturz', async ({ page }) => {
await gotoOk(page, '/bank/upload')
await page.setInputFiles('#bank-dnd-input', {
name: 'kaputt.csv',
mimeType: 'text/csv',
buffer: Buffer.from('nur eine zeile ohne struktur', 'utf-8'),
})
// Erkennt die App kein passendes Format, fällt sie bewusst auf das erste
// bekannte zurück (siehe upload.php) — der Import bleibt also möglich.
// Geprüft wird deshalb nur, dass nichts abstürzt und die Seite bedienbar bleibt.
await page.waitForTimeout(3000)
await expect(page.locator('body')).not.toContainText('Ein Fehler ist aufgetreten')
await expect(page.locator('#fmt-select')).toBeVisible()
})
})

View file

@ -0,0 +1,140 @@
// @ts-check
// Hotspot: Beleg-Upload → Buchungsmaske → gespeicherte Buchung.
//
// Der Netto/Brutto-Umschalter (Issue #137) ist reines JavaScript: Im Brutto-Modus
// steht im Feld `betrag_netto` ein Bruttowert, den ein submit-Handler kurz vor dem
// Absenden in den Nettobetrag umrechnet (src/Views/belege/detail.php).
// Ohne Browser ist dieser Pfad nicht prüfbar — ein PHP-Test sieht immer nur den
// bereits umgerechneten Wert und würde eine kaputte Umrechnung nie bemerken.
const { test, expect } = require('@playwright/test')
const { uploadBeleg, cleanupBeleg, gotoOk, parseMoney } = require('../helpers/app')
const KATEGORIE_KONTO = '4930' // Bürobedarf (Betriebsausgabe)
const ZAHLUNGS_KONTO = '1200' // Bank
/** Wählt im Konto-Select die Option mit der gegebenen Kontonummer. */
async function waehleKonto(page, selectId, kontonummer) {
const option = page.locator(`#${selectId} option`).filter({ hasText: new RegExp(`^\\s*${kontonummer}\\s+–`) }).first()
await expect(option, `Konto ${kontonummer} nicht in #${selectId} gefunden`).toHaveCount(1)
const value = await option.getAttribute('value')
await page.selectOption(`#${selectId}`, value)
return value
}
/**
* Füllt die Buchungsmaske bis auf den Betrag aus.
*
* Reihenfolge beachten: Die Auswahl des Kategorie-Kontos ruft updateAutoUst()
* auf und setzt den USt-Satz passend zum Steuercode des Kontos. Der Satz muss
* deshalb nach der Kontoauswahl gesetzt werden — sonst wird er überschrieben.
*/
async function buchungsmaskeVorbereiten(page, { ustSatz = '19', buchungstext }) {
await page.check('input[name="buchungstyp"][value="Ausgabe"]')
await waehleKonto(page, 'kategorie_konto_id', KATEGORIE_KONTO)
await waehleKonto(page, 'zahlungskonto_id', ZAHLUNGS_KONTO)
await page.selectOption('#ust_satz_select', ustSatz)
await page.fill('input[name="buchungstext"]', buchungstext)
}
test.describe('Beleg-Upload und Buchung', () => {
let belegId = 0
test.afterEach(async ({ page }) => {
await cleanupBeleg(page, belegId)
belegId = 0
})
test('Beleg wird hochgeladen und erscheint in der Belegliste', async ({ page }) => {
const { id, tag } = await uploadBeleg(page)
belegId = id
await expect(page.locator('body')).toContainText(tag)
await gotoOk(page, '/belege')
await expect(page.locator('body')).toContainText(tag)
})
test('Buchung im NETTO-Modus speichert den eingegebenen Wert unverändert', async ({ page }) => {
const { id, tag } = await uploadBeleg(page)
belegId = id
await buchungsmaskeVorbereiten(page, { buchungstext: tag })
// Netto ist der Default-Modus.
await expect(page.locator('#betrag_modus_netto_btn')).toHaveAttribute('aria-pressed', 'true')
await page.fill('#betrag_netto_input', '100.00')
// Live-Anzeige rechnet vorwärts: Netto 100 → USt 19 → Brutto 119.
await expect(page.locator('#ust_brutto_anzeige')).toContainText('19,00')
await expect(page.locator('#ust_brutto_anzeige')).toContainText('119,00')
await page.click('#buchung_form button[type="submit"]')
await page.waitForLoadState('networkidle')
const zeile = page.locator('table tbody tr', { hasText: tag }).first()
await expect(zeile).toHaveCount(1)
expect(parseMoney(await zeile.locator('td').nth(5).textContent())).toBeCloseTo(100.0, 2)
})
test('Buchung im BRUTTO-Modus wird vor dem Absenden in Netto umgerechnet', async ({ page }) => {
const { id, tag } = await uploadBeleg(page)
belegId = id
await buchungsmaskeVorbereiten(page, { buchungstext: tag })
await page.click('#betrag_modus_brutto_btn')
await expect(page.locator('#betrag_modus_brutto_btn')).toHaveAttribute('aria-pressed', 'true')
await expect(page.locator('#betrag_modus_netto_btn')).toHaveAttribute('aria-pressed', 'false')
// 119,00 brutto bei 19 % → 100,00 netto.
await page.fill('#betrag_netto_input', '119.00')
await expect(page.locator('#ust_brutto_anzeige')).toContainText('100,00')
await page.click('#buchung_form button[type="submit"]')
await page.waitForLoadState('networkidle')
const zeile = page.locator('table tbody tr', { hasText: tag }).first()
await expect(zeile, 'Buchung wurde nicht gespeichert').toHaveCount(1)
// Kern der Regression: Gespeichert werden muss der Nettobetrag 100,00 —
// nicht die eingegebenen 119,00.
const gespeichert = parseMoney(await zeile.locator('td').nth(5).textContent())
expect(gespeichert, 'Bruttoeingabe wurde nicht in Netto umgerechnet').toBeCloseTo(100.0, 2)
})
test('Moduswechsel rechnet den bereits eingegebenen Betrag um', async ({ page }) => {
const { id } = await uploadBeleg(page)
belegId = id
await page.selectOption('#ust_satz_select', '19')
await page.fill('#betrag_netto_input', '100.00')
// Netto → Brutto
await page.click('#betrag_modus_brutto_btn')
await expect(page.locator('#betrag_netto_input')).toHaveValue('119.00')
// und wieder zurück
await page.click('#betrag_modus_netto_btn')
await expect(page.locator('#betrag_netto_input')).toHaveValue('100.00')
})
test('Buchung mit 7 % USt rechnet im Brutto-Modus korrekt', async ({ page }) => {
const { id, tag } = await uploadBeleg(page)
belegId = id
await buchungsmaskeVorbereiten(page, { ustSatz: '7', buchungstext: tag })
await page.click('#betrag_modus_brutto_btn')
await page.fill('#betrag_netto_input', '107.00')
await page.click('#buchung_form button[type="submit"]')
await page.waitForLoadState('networkidle')
const zeile = page.locator('table tbody tr', { hasText: tag }).first()
await expect(zeile).toHaveCount(1)
expect(parseMoney(await zeile.locator('td').nth(5).textContent())).toBeCloseTo(100.0, 2)
})
// Hinweis: Dass ein Beleg mit Buchungen nicht löschbar ist, ist reine
// Model-Logik (Beleg::delete) und wird in tests/77_BelegLoeschsperreTest.php
// geprüft — dort schneller, isoliert und ohne Browser.
})

View file

@ -0,0 +1,166 @@
// @ts-check
// Hotspot: Rechnung anlegen → finalisieren → PDF- und XML-Export.
//
// Der XML-Download (EN 16931 / XRechnung 3.0) kam zuletzt zweimal in Folge als
// Fix ins Repo — einmal der Export selbst, danach die USt-IdNr. des Kunden
// (BT-48), die im XML fehlte. Beide Male hing der Fehler am Zusammenspiel aus
// Controller, ZugferdService und den tatsächlich gespeicherten Rechnungsdaten.
//
// Hinweis: Der Finalisierungs-Test vergibt eine Rechnungsnummer, gibt sie beim
// Löschen der Testrechnung aber wieder frei (Invoice::delete). Der Nummernkreis
// steht nach dem Testlauf also wieder da, wo er vorher stand — der letzte Test
// dieser Datei weist genau das nach. Siehe README.md.
const { test, expect } = require('@playwright/test')
const { uniqueTag, gotoOk, acceptDialogs, cleanupInvoice, parseMoney } = require('../helpers/app')
/** Legt einen Rechnungsentwurf mit einer Position an und liefert die ID. */
async function entwurfAnlegen(page, { tag, einzelpreis = '100.00', ustSatz = '19' }) {
await gotoOk(page, '/invoices/new')
// Ersten echten Kunden wählen (Index 0 ist der Platzhalter).
const kundenOptionen = page.locator('#customer_id option')
const anzahl = await kundenOptionen.count()
expect(anzahl, 'Kein Kunde vorhanden — die Instanz braucht mindestens einen Kunden').toBeGreaterThan(1)
const kundenValue = await kundenOptionen.nth(1).getAttribute('value')
await page.selectOption('#customer_id', kundenValue)
await page.fill('#betreff', tag)
// Position über den "+"-Button und das Popup hinzufügen.
await page.click('button[onclick^="showAddLineDialog"]')
await page.click('#add-line-popup >> text=Neue Position')
const zeile = page.locator('#invoice-lines-body tr').last()
await zeile.locator('textarea[name$="[bezeichnung]"]').fill(`Leistung ${tag}`)
await zeile.locator('input[name$="[menge]"]').fill('1')
await zeile.locator('select[name$="[ust_satz]"]').selectOption(ustSatz)
await zeile.locator('input[name$="[einzelpreis]"]').fill(einzelpreis)
await Promise.all([
page.waitForURL(/\/invoices\/\d+/),
page.click('button[type="submit"]:has-text("Entwurf speichern")'),
])
const id = Number(page.url().match(/\/invoices\/(\d+)/)?.[1] || 0)
expect(id, `Rechnungs-ID nicht ermittelbar: ${page.url()}`).toBeGreaterThan(0)
return id
}
/** Finalisiert den Entwurf über den Button in der Detailansicht. */
async function finalisieren(page, invoiceId) {
acceptDialogs(page)
await page.click('button[type="submit"]:has-text("Finalisieren")')
await page.waitForLoadState('networkidle')
await expect(page.locator('body'), 'Finalisieren hat den Status nicht geändert')
.not.toContainText('Finalisieren & Nummerieren')
}
/**
* Liest die vergebene Rechnungsnummer aus dem Dateinamen des XML-Downloads.
* Sie steht nirgends sonst maschinell eindeutig auf der Seite.
*/
async function rechnungsnummerAusXml(page, invoiceId) {
const response = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(response.status()).toBe(200)
const dateiname = response.headers()['content-disposition'] || ''
const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1]
expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy()
expect(nummer).not.toBe('Entwurf')
return nummer
}
test.describe('Rechnung: Anlegen, Finalisieren, Export', () => {
let invoiceId = 0
test.afterEach(async ({ page }) => {
await cleanupInvoice(page, invoiceId)
invoiceId = 0
})
test('Entwurf wird mit Position und Summen gespeichert', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
await expect(page.locator('body')).toContainText(tag)
await expect(page.locator('body')).toContainText('Entwurf')
// 100,00 netto + 19 % = 119,00 brutto müssen irgendwo auf der Seite stehen.
await expect(page.locator('body')).toContainText('119,00')
})
test('XML-Export eines Entwurfs liefert wohlgeformtes XRechnung-XML', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
const response = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toContain('xml')
const xml = await response.text()
expect(xml.startsWith('<?xml'), 'Antwort ist kein XML-Dokument').toBe(true)
// Root-Element der CII-Syntax, die XRechnung 3.0 verwendet.
expect(xml).toContain('CrossIndustryInvoice')
// Kein unaufgelöstes PHP und keine Fehlerseite im Download.
expect(xml).not.toContain('<?php')
expect(xml).not.toContain('Ein Fehler ist aufgetreten')
})
test('Finalisieren vergibt eine Nummer und liefert PDF und XML aus', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
await finalisieren(page, invoiceId)
const seitentext = await page.locator('body').textContent()
expect(seitentext).toContain(tag)
// ── PDF ──
const pdf = await page.request.get(`/invoices/${invoiceId}/pdf`)
expect(pdf.status()).toBe(200)
const pdfBody = await pdf.body()
expect(pdfBody.subarray(0, 5).toString('latin1'), 'Kein gültiger PDF-Header').toBe('%PDF-')
expect(pdfBody.length).toBeGreaterThan(1000)
// ── XML ──
const xmlResponse = await page.request.get(`/invoices/${invoiceId}/xml`)
expect(xmlResponse.status()).toBe(200)
const xml = await xmlResponse.text()
expect(xml).toContain('CrossIndustryInvoice')
// Die vergebene Rechnungsnummer muss im XML stehen (BT-1).
const nummer = await rechnungsnummerAusXml(page, invoiceId)
expect(xml).toContain(nummer)
})
test('Löschen einer finalisierten Rechnung gibt die Nummer wieder frei', async ({ page }) => {
// Der Testlauf darf keine Nummer aus dem laufenden Kreis verbrauchen:
// Invoice::delete() zählt den Nummernkreis zurück, wenn die gelöschte
// Rechnung die zuletzt nummerierte war. Nachweis über die Nummer der
// nächsten Rechnung — sie muss dieselbe sein.
const ersteId = await entwurfAnlegen(page, { tag: uniqueTag() })
await finalisieren(page, ersteId)
const ersteNummer = await rechnungsnummerAusXml(page, ersteId)
await cleanupInvoice(page, ersteId)
await expect(
page.locator(`form[action="/invoices/${ersteId}/delete"]`),
'Die finalisierte Testrechnung wurde nicht gelöscht'
).toHaveCount(0)
invoiceId = await entwurfAnlegen(page, { tag: uniqueTag() })
await finalisieren(page, invoiceId)
expect(
await rechnungsnummerAusXml(page, invoiceId),
'Der Nummernkreis wurde beim Löschen nicht zurückgesetzt'
).toBe(ersteNummer)
})
test('PDF eines Entwurfs wird nicht ausgeliefert', async ({ page }) => {
const tag = uniqueTag()
invoiceId = await entwurfAnlegen(page, { tag })
// Im Entwurf gibt es bewusst keinen PDF-Link (GoBD: erst ab Finalisierung).
await expect(page.locator(`a[href="/invoices/${invoiceId}/pdf"]`)).toHaveCount(0)
})
})

View file

@ -0,0 +1,79 @@
// @ts-check
// Smoke-Tests: Jede Hauptseite muss ohne Serverfehler rendern.
//
// Diese Ebene fehlte bisher komplett — die PHP-Testsuite prüft Models und
// Services, aber nie das Zusammenspiel aus Router, Controller, View und
// Layout. Genau dort schlugen zuletzt Fehler nach Updates auf.
const { test, expect } = require('@playwright/test')
const { gotoOk, expectNoServerError } = require('../helpers/app')
const jahr = new Date().getFullYear()
const monat = String(new Date().getMonth() + 1).padStart(2, '0')
/** Lesende Seiten, die ohne Vorbedingungen erreichbar sein müssen. */
const seiten = [
['Dashboard', '/'],
['Dashboard (explizit)', '/dashboard'],
['Kontenrahmen', '/accounts'],
['Konto anlegen', '/accounts/new'],
['Belegliste', '/belege'],
['Beleg hochladen', '/belege/upload'],
['Bank buchen', '/bank'],
['Bank – Direktabruf', '/bank/direkt'],
['Bank – Upload', '/bank/upload'],
['Journal', '/journal'],
['Lieferanten', '/suppliers'],
['Lieferant anlegen', '/suppliers/new'],
['Kunden', '/customers'],
['Kunde anlegen', '/customers/new'],
['Rechnungen', '/invoices'],
['Rechnung anlegen', '/invoices/new'],
['Anlagevermögen', '/assets'],
['Anlage anlegen', '/assets/new'],
['UStVA', '/tax/ustva'],
['UStVA – Monat', `/tax/ustva/${jahr}/${monat}`],
['EÜR', '/tax/euer'],
['EÜR – Jahr', `/tax/euer/${jahr}`],
['Einstellungen', '/settings'],
['Einstellungen – Bankimport', '/settings/bankimport'],
['Buchungsvorlagen', '/einstellungen/buchungsvorlagen'],
['Buchungsvorlage anlegen', '/einstellungen/buchungsvorlagen/neu'],
['Ereignisse', '/ereignisse'],
['Benutzerverwaltung', '/useradmin'],
]
test.describe('Smoke: Hauptseiten rendern', () => {
for (const [name, pfad] of seiten) {
test(`${name} (${pfad})`, async ({ page }) => {
await gotoOk(page, pfad)
// Das Layout muss stehen — sonst ist die Seite nur halb gerendert.
await expect(page.locator('nav, .nav, #nav').first()).toBeVisible()
})
}
})
test.describe('Smoke: Grundverhalten', () => {
test('Unbekannte Route liefert 404 statt Serverfehler', async ({ page }) => {
const response = await page.goto('/gibt-es-nicht-e2e')
expect(response.status()).toBe(404)
await expectNoServerError(page)
})
test('Nicht angemeldet führt zum Login', async ({ browser }) => {
// Eigener Kontext ohne gespeicherten Session-Zustand.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
const page = await context.newPage()
await page.goto('/belege')
await expect(page).toHaveURL(/\/login/)
await expect(page.locator('#username')).toBeVisible()
await context.close()
})
test('CSV-Export der Belegliste liefert eine Datei', async ({ page }) => {
const response = await page.request.get('/belege/export')
expect(response.status()).toBe(200)
const disposition = response.headers()['content-disposition'] || ''
expect(disposition).toContain('attachment')
})
})