Fix #e2e: E2E-Tests ergänzt, /accounts/new und /assets/new lieferten HTTP 500
Steuerkram an den e2e-runner angebunden (tests/e2e/, 44 Playwright-Tests). Der erste Smoke-Lauf fand zwei Seiten mit HTTP 500: AccountController::create las $_GET['from'] ungeprüft, assets/form.php nutzte $testMode und $defaultKontoId außerhalb des Bearbeiten-Zweigs. Beides war bis zur Fehler-Härtung (#142) nur eine stille Warning. Test 67 räumt sein Belegverzeichnis jetzt mit ab - es blieb als root-eigenes Verzeichnis liegen und blockierte spätere echte Uploads mit HTTP 500. Test 77 (neu) sichert die Löschsperre von Beleg::delete() ab.
This commit is contained in:
parent
be234eeb2c
commit
7cea573ffc
24 changed files with 1303 additions and 2 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -37,6 +37,24 @@ $this->assertGreaterThan($min, $wert, 'Beschreibung');
|
||||||
$this->assertNotEmpty($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
|
## Dokumentation aktuell halten
|
||||||
|
|
||||||
| Datei | Wann aktualisieren |
|
| Datei | Wann aktualisieren |
|
||||||
|
|
|
||||||
11
Changes.md
11
Changes.md
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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 |
|
| [Datenbankstruktur](docs/datenbankstruktur.md) | Tabellen, Spalten, Fremdschlüssel, GoBD-Mechanismen |
|
||||||
| [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln |
|
| [Berechnungslogik](docs/berechnungslogik.md) | UStVA-Kennzahlen und EÜR-Zuordnungsregeln |
|
||||||
| [Kontenrahmen](docs/kontenrahmen.md) | Vollständige SKR03-Kontenliste |
|
| [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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
105
scripts/e2e-cleanup.php
Normal file
105
scripts/e2e-cleanup.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
#!/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$belege = Database::fetchAll(
|
||||||
|
'SELECT id, beleg_nr, filename FROM belege WHERE notes LIKE ? OR filename LIKE ?',
|
||||||
|
[MARKER, MARKER]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rechnungen = Database::fetchAll(
|
||||||
|
'SELECT id, rechnungs_nr, betreff, status FROM invoices WHERE betreff LIKE ?',
|
||||||
|
[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
84
scripts/e2e-user.php
Normal 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";
|
||||||
|
|
@ -141,7 +141,7 @@ class AccountController extends BaseController
|
||||||
|
|
||||||
public function create(array $params): void
|
public function create(array $params): void
|
||||||
{
|
{
|
||||||
$back = $_GET['from'] === 'settings' ? '/settings' : '/accounts';
|
$back = ($_GET['from'] ?? '') === 'settings' ? '/settings' : '/accounts';
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
'pageTitle' => 'Konto anlegen',
|
'pageTitle' => 'Konto anlegen',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class AssetController extends BaseController
|
||||||
'action' => '/assets',
|
'action' => '/assets',
|
||||||
'anlagearten' => Asset::anlagearten(),
|
'anlagearten' => Asset::anlagearten(),
|
||||||
'scheduleRows' => [],
|
'scheduleRows' => [],
|
||||||
|
'testMode' => Setting::isTestMode(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ $centFlag = !empty($asset['cent_im_ersten_jahr']);
|
||||||
$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true;
|
$autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automatisch_berechnet'] : true;
|
||||||
$bkHints = $bookingHints ?? [];
|
$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 {
|
$conflictBadge = function(string $field, string $label) use ($bkHints, $h): string {
|
||||||
if (!isset($bkHints[$field])) return '';
|
if (!isset($bkHints[$field])) return '';
|
||||||
$val = $h($bkHints[$field]);
|
$val = $h($bkHints[$field]);
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1-260801
|
1-260802
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ class BelegXmlXsltXssTest extends TestCase
|
||||||
'Der Content-Disposition-Header nutzt die ermittelte Disposition (nicht fest inline)'
|
'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);
|
@unlink($absPath);
|
||||||
|
@rmdir($absDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
tests/77_BelegLoeschsperreTest.php
Normal file
103
tests/77_BelegLoeschsperreTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tests/e2e/.gitignore
vendored
Normal file
4
tests/e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.auth/
|
||||||
123
tests/e2e/README.md
Normal file
123
tests/e2e/README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nicht umkehrbare Nebenwirkung
|
||||||
|
|
||||||
|
`rechnung-export.spec.js` finalisiert eine Rechnung und **verbraucht dabei eine
|
||||||
|
Nummer aus dem laufenden Nummernkreis**. Die Rechnung selbst wird wieder gelöscht,
|
||||||
|
die Nummer bleibt vergeben (GoBD — eine Rücknahme wäre auch nicht wünschenswert).
|
||||||
|
Wer das vermeiden will, lässt diesen Test weg:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
... npx playwright test --grep-invert "Finalisieren"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
48
tests/e2e/global-setup.js
Normal 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
182
tests/e2e/helpers/app.js
Normal 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
8
tests/e2e/helpers/env.js
Normal 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
76
tests/e2e/package-lock.json
generated
Normal 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
8
tests/e2e/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/e2e/playwright.config.js
Normal file
21
tests/e2e/playwright.config.js
Normal 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
32
tests/e2e/run.sh
Executable 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
|
||||||
106
tests/e2e/specs/bank-import.spec.js
Normal file
106
tests/e2e/specs/bank-import.spec.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
140
tests/e2e/specs/beleg-buchung.spec.js
Normal file
140
tests/e2e/specs/beleg-buchung.spec.js
Normal 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.
|
||||||
|
})
|
||||||
126
tests/e2e/specs/rechnung-export.spec.js
Normal file
126
tests/e2e/specs/rechnung-export.spec.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// @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 verbraucht eine Rechnungsnummer aus dem
|
||||||
|
// laufenden Nummernkreis. Das lässt sich nicht zurücknehmen (GoBD) — die
|
||||||
|
// Rechnung selbst wird im Testmodus wieder gelöscht. 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
acceptDialogs(page)
|
||||||
|
|
||||||
|
await page.click('button[type="submit"]:has-text("Finalisieren")')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Status darf nicht mehr "Entwurf" sein und es muss eine Nummer geben.
|
||||||
|
await expect(page.locator('body')).not.toContainText('Finalisieren & Nummerieren')
|
||||||
|
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 dateiname = xmlResponse.headers()['content-disposition'] || ''
|
||||||
|
const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1]
|
||||||
|
expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy()
|
||||||
|
expect(nummer).not.toBe('Entwurf')
|
||||||
|
expect(xml).toContain(nummer)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
79
tests/e2e/specs/smoke.spec.js
Normal file
79
tests/e2e/specs/smoke.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue