Compare commits

...

3 Commits

Author SHA1 Message Date
Majd Ibrahim 762f21dfa1 packagestruktur angepasst 2026-09-25 11:32:18 +00:00
Majd Ibrahim 4447282b76 Merge remote-tracking branch 'origin/feature/paket-buchen-backend' into majds 2026-09-24 16:40:04 +00:00
Marvin Gemlin 6bf2e17015 feat: Paketbuchung mit CSV, SMTP und fortlaufenden Buchungsnummern 2026-09-24 16:11:54 +00:00
28 changed files with 2121 additions and 3 deletions

12
.env.example 100644
View File

@ -0,0 +1,12 @@
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1
PORT=3000
BOOKING_DEMO=true
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM="Tri-Hub <buchung@example.test>"
# Für echtes SMTP erst nach ausdrücklicher Freigabe ändern.
SMTP_ALLOW_EXTERNAL=false

6
.gitignore vendored
View File

@ -6,3 +6,9 @@ dist/
.DS_Store
Thumbs.db
*.log
# Private Buchungsdaten und lokale Testergebnisse
src/server/data/*
!src/server/data/.gitkeep
playwright-report/
test-results/

View File

@ -1,3 +1,7 @@
node_modules/
dist/
package-lock.json
src/server/data/
playwright-report/
test-results/

16
booking.html 100644
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Paket buchen – Tri-Hub</title>
</head>
<body>
<main id="booking-root" class="container"></main>
<script
type="module"
src="/src/features/booking/booking-entry.js"
></script>
</body>
</html>

353
docs/booking.md 100644
View File

@ -0,0 +1,353 @@
# US2.4 – Paket buchen
## Stand und Zusammenarbeit
Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
Commit oder Push wurde durchgeführt. Es gab keinen Router, keine Paketdaten und
kein Backend. Die Implementierung verwendet die vereinbarte Startstruktur:
Backend unter `src/server/`, gemeinsame Paketdaten unter `src/shared/` und
Tests unter `src/tests/`. Die früheren Doppelordner im Projektwurzelverzeichnis
wurden in diese vorhandenen Ordner überführt.
## So funktioniert der Ablauf
1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
3. Der Server prüft die Angaben und liest den Paketnamen aus `src/shared/packages.js`.
4. Er speichert Buchungsnummer, Zeitpunkt, Angaben und Schlüssel in der CSV.
5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt
er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
## Lokal starten
Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
```bash
npm ci
cp .env.example .env
```
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
Ohne `.env` bleibt der Demo-Modus ausgeschaltet und der echte Paketkatalog leer.
Einmalig [Mailpit installieren](https://mailpit.axllent.org/docs/install/).
Es läuft als einzelne Binärdatei auch direkt im Dev Container. Kein Docker im
Container erforderlich. Mailpit ohne Weiterleitung an echte SMTP-Server starten:
```bash
mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check
```
In Terminal 2 das Backend starten:
```bash
npm run dev:server
```
In Terminal 3 das Frontend starten:
```bash
npm run dev
```
Öffnen: <http://localhost:5173/booking.html#/buchen/demo-booking>.
Mailfänger: <http://localhost:8025>. Im Dev Container bei Bedarf Port 8025 über
VS Codes Ansicht „Ports“ weiterleiten. Port 5173 ist bereits vorkonfiguriert.
Port 3000 muss für den Browser nicht weitergeleitet werden: Vite vermittelt `/api`.
Diese Beschreibung setzt voraus, dass Mailpit im selben Container läuft.
`npm run start:server` startet das Backend ohne automatischen Neustart.
Bestehende Befehle `dev`, `build`, `preview`, `format` und `format:check` bleiben erhalten.
`npm run preview` zeigt ausschließlich den gebauten Frontend-Stand. Für eine
vollständige Buchung im Deployment muss der Webserver `/api` separat an Node
weiterleiten; der Vite-Entwicklungsproxy ist kein Produktionsserver.
## Konfiguration
Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
| Variable | Lokaler Beispielwert | Bedeutung |
| ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
| `HOST` | `127.0.0.1` | Bind-Adresse des Backends |
| `PORT` | `3000` | Backend-Port; bei Änderung auch Vite-Proxy anpassen |
| `BOOKING_DEMO` | `true` | Fügt ausschließlich lokal das Testpaket hinzu; in Produktion gesperrt |
| `SMTP_HOST` | `127.0.0.1` | Mailfänger im selben Container |
| `SMTP_PORT` | `1025` | SMTP-Port des Mailfängers |
| `SMTP_SECURE` | `false` | Bei echtem SMTP mit direktem TLS üblicherweise `true` auf 465 |
| `SMTP_USER` / `SMTP_PASS` | leer | Beide gemeinsam setzen, falls Authentifizierung benötigt wird |
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender; später abgestimmten echten Absender verwenden |
| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
Für externe SMTP-Hosts wird TLS verlangt; Zertifikatsprüfung bleibt aktiv.
Für STARTTLS (typischerweise Port 587) bleibt `SMTP_SECURE=false`.
Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
[SMTP-Optionen bei Nodemailer](https://nodemailer.com/smtp).
## Paketdaten und spätere Router-Anbindung
Person 3 pflegt `packages` in **`src/shared/packages.js`**.
Das derzeit leere Array enthält später ausschließlich abgestimmte Angebote:
```js
// Nur Schema-Beispiel, kein verbindliches Angebot:
{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
```
IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
nicht für andere Angebote wiederverwenden. Das Backend prüft den Vertrag beim Start.
Das separate `demoPackages`-Array in derselben Datei enthält nur `demo-booking`,
explizit als Test markiert. Es wird mit `BOOKING_DEMO=true` zugeschaltet.
Keine Preise wurden erfunden. Sobald verbindliche Preisfelder vereinbart sind,
müssen Anzeige und serverseitiger Buchungssnapshot gemeinsam ergänzt werden.
Angaben wie `packageName` oder `price` aus API-Anfragen werden nicht übernommen.
Die Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
```js
link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
```
Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
```js
import { mountBookingPage } from "./features/booking/booking-page.js";
const cleanup = mountBookingPage(container, { packageId });
// Beim Verlassen der Route:
cleanup();
```
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
der anderen Teammitglieder. „Zur Angebotsseite“ zeigt vorerst auf `/#beratung`.
## API-Beispiele
`GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
Für `POST /api/bookings` sind `Content-Type: application/json` und ein
`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
```http
POST /api/bookings
Content-Type: application/json
Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
{"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
```
Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
```json
{
"bookingId": "TH-000001",
"createdAt": "2026-09-24T12:00:00.000Z",
"packageId": "demo-booking",
"packageName": "1:1-Ernährungsberatung",
"saved": true,
"emailStatus": "accepted",
"replayed": false
}
```
Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
| Status/Code | Bedeutung und Verhalten |
| ------------------------------ | ----------------------------------------------------------------------- |
| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
| `400 UNKNOWN_PACKAGE` | Paket nicht verfügbar; verfügbares Paket auswählen |
| `400 INVALID_KEY` | UUID-v4-Anfrageschlüssel fehlt oder ist ungültig |
| `400 INVALID_JSON` | Anfrage ist kein gültiges JSON |
| `409 IDEMPOTENCY_CONFLICT` | Derselbe Schlüssel wurde mit anderen Angaben verwendet |
| `413 BODY_TOO_LARGE` | Mehr als 8 KiB Anfrageinhalt |
| `415 UNSUPPORTED_CONTENT_TYPE` | JSON-Content-Type erforderlich |
| `503 BOOKING_NOT_SAVED` | Speicherung fehlgeschlagen; mit gleichem Schlüssel wiederholen |
| `503 STORAGE_UNAVAILABLE` | Vorhandener Status nicht lesbar; mit gleichem Schlüssel wiederholen |
| `500 INTERNAL_ERROR` | Status unklar; keine neue Buchung erzeugen, gleiche Anfrage wiederholen |
Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
dieses Sprints. Eine Formatprüfung beweist nicht, dass das Postfach existiert.
Beispiel für einen Validierungsfehler:
```json
{
"error": {
"code": "INVALID_INPUT",
"message": "Bitte die markierten Angaben prüfen.",
"fields": { "email": "Bitte eine gültige E-Mail-Adresse eingeben." }
}
}
```
## Simulation und spätere Zahlung
Das Demonstrationspaket heißt in der Oberfläche „1:1-Ernährungsberatung“.
Die ID `demo-booking` und die Freigabe über `BOOKING_DEMO` bleiben unverändert;
es wurden keine verbindlichen Angebote, Leistungsumfänge oder Preise ergänzt.
Ein gemeinsamer Hinweis aus `src/shared/booking-copy.js` erklärt vor dem Absenden
und in der E-Mail, dass der Ablauf simuliert ist: Der Zahlungsvorgang wird
übersprungen, es wird nichts abgebucht und keine verbindliche Beratung gebucht.
Bei vollständiger Integration kann der Zahlungsschritt ergänzt werden. Dafür
müssen tatsächliche Zahlungsbestätigung und Buchungsstatus serverseitig verbunden
werden; das Entfernen des Hinweises allein aktiviert keine Zahlung.
## Fortlaufende Buchungsnummern
Neue Buchungen erhalten `TH-000001`, `TH-000002` usw. Dieselbe Nummer steht in der
Antwort, CSV und E-Mail. Sie wird innerhalb der Warteschlange aus dem höchsten
vorhandenen TH-Wert berechnet und mit der Buchung gespeichert. Ein Neustart setzt
sie nicht zurück; Wiederholungen behalten dieselbe Nummer. Sechs Stellen sind
die Mindestbreite, nach `TH-999999` folgt `TH-1000000`. Es gibt keinen Jahresreset.
Bestehende UUID-Buchungen und gespeicherte Browserbestätigungen behalten ihre
bisherige Nummer, damit bereits ausgegebene Referenzen gültig bleiben. Nur neue
Buchungen verwenden das neue Format. Der technische Idempotency-Key bleibt eine
zufällige UUID; die lesbare Buchungsnummer ist kein Geheimnis oder Zugriffstoken.
Der Zähler setzt voraus, dass die CSV vollständig erhalten bleibt. Löschen von
Zeilen, Entfernen der Datei oder Zurückspielen eines älteren Backups kann Nummern
wiederverwendbar machen. Vor einer Archivierungs-/Löschfunktion ist ein separat
persistierter, transaktionaler Zähler nötig. Weiterhin nur ein Backend-Prozess.
## CSV, Wiederholungen und Fehlergrenzen
Pfad: `src/server/data/bookings.csv`. Spalten: `bookingId`, `createdAt`, `packageId`,
`packageName`, `name`, `email`, `emailStatus`, `idempotencyKey`, `requestHash`.
`requestHash` verknüpft den Schlüssel mit den normalisierten Anfragedaten, ohne
sie in einer zweiten Datenquelle zu duplizieren. Bereits gespeicherte Buchungen
können auch nach dem Entfernen eines Pakets weiterhin wiederholt abgefragt werden.
CSV-Bibliotheken behandeln Kommas, Anführungszeichen und Zeilenumbrüche. Zellen,
die wie Tabellenformeln beginnen, erhalten ein Apostroph. Bereits führende
Apostrophe werden ebenfalls maskiert, damit internes Lesen die Originalwerte
wiederherstellen kann. Persönliche Namen dürfen in der API keine Steuerzeichen
enthalten; die Speicherschicht unterstützt Zeilenumbrüche für andere CSV-Felder.
Dateien werden mit Zugriffsmodus `0600` geschrieben.
Eine Warteschlange führt komplette Buchungen nacheinander aus. Schreiben erfolgt
über eine temporäre Datei und atomisches Umbenennen. **Nur ein laufender
Backend-Prozess auf einem lokalen Dateisystem wird unterstützt.** Keine Cluster,
mehreren Containerinstanzen, manuelle Dateiedits während des Betriebs oder
Netzlaufwerke. Die gesamte CSV wird bei Änderungen gelesen/neu geschrieben;
geeignet für den ersten Sprint mit kleinem Volumen. Es gibt keine verteilte
Transaktion oder vollständige Garantie gegen Hardware-/Stromausfall.
Versandzustände:
- `pending`: Buchung gespeichert, Versand noch nicht gestartet. Derselbe Schlüssel
darf den Versand nach einem Schreibfehler fortsetzen.
- `sending`: Versandabsicht dauerhaft gespeichert. Bei Prozessabbruch zeigt die
API diesen Zustand als `unknown`; kein automatischer Neuversand.
- `accepted`: SMTP hat den Empfänger und die Nachricht angenommen.
- `failed`: Der Versand ist fehlgeschlagen bzw. wurde ausdrücklich abgelehnt.
- `unknown`: Verbindung oder Statusspeicherung brach zu einem unklaren Zeitpunkt ab.
Für `failed` und `unknown` gibt es bewusst keinen automatischen erneuten Versand.
Erst CSV und Mailserver/Mailfänger anhand der Buchungsnummer prüfen. Eine
administrative Wiederholungsfunktion ist offen; keinesfalls eine neue Buchung
anlegen oder `sending` blind zurücksetzen. SMTP und CSV können nicht gemeinsam
atomar abgeschlossen werden, weshalb ein exakt einmaliger Versand nicht garantiert
werden kann. Ein Verbindungsabbruch nach SMTP-Annahme kann doppelte Mails verursachen,
wenn später ohne Prüfung manuell erneut gesendet wird.
Das Formular speichert Schlüssel und Angaben je Paket in `sessionStorage`, also
für den aktuellen Browser-Tab, auch über Neuladen hinweg. Bei unklarem Ergebnis
bleiben die Angaben schreibgeschützt und können mit demselben Schlüssel erneut
geprüft werden. Nach gespeicherter Buchung bleibt das Formular gesperrt. Ein
bewusst neuer Auftrag benötigt einen neuen Tab bzw. eine neue Sitzung. Das
Schließen des Tabs, gelöschter Sitzungsspeicher, ein anderes Gerät oder ein neuer
Schlüssel liegen außerhalb der Doppelbuchungsabsicherung. Bei unklarem Ausgang
zuerst den bestehenden Status prüfen. Es gibt keine globale Erkennung anhand
von Name/E-Mail, weil zwei bewusst getrennte Buchungen möglich sein müssen.
`.gitignore` schließt Buchungsdaten einschließlich temporärer Dateien und `.env`
aus. Das Backend liefert ausschließlich API-Antworten aus. Vite blockiert
Serververzeichnis und Umgebungsdateien auch beim direkten Dateizugriff. Im
Deployment nur `dist/` öffentlich bereitstellen, nie den gesamten Projektordner.
## Prüfen
Automatisierte Tests erzeugen temporäre CSV-Dateien und simulieren E-Mail-Versand.
Sie schreiben nicht in `src/server/data/bookings.csv` und kontaktieren kein echtes SMTP.
Vor Browsertests lokale Server auf 3000/5173 stoppen; Playwright startet eigene
Testserver und übernimmt keine bereits laufenden Instanzen.
```bash
npm test
npx playwright install --with-deps chromium
npm run test:browser
npm run format:check
npm run build
```
Die einmalige Chromium-Installation benötigt Netzwerk und unter Linux eventuell
Systempakete. Der Dev Container wurde dafür nicht global umkonfiguriert.
Manuelle Abnahme im lokalen Mailfänger:
1. Demo-Modus, Mailpit, Backend und Vite wie oben starten.
2. Testpaket mit `person@example.test` buchen. Buchungsnummer notieren.
3. CSV im Editor öffnen: genau eine passende Zeile, Paket, Zeitpunkt und Versandstatus prüfen.
4. In Mailpit die Nachricht öffnen: Empfänger, Paket und Buchungsnummer vergleichen.
5. Seite neu laden: gleiche Buchungsnummer, keine neue CSV-Zeile und keine zweite Mail.
6. Mit Tab/Shift+Tab und Enter bedienen, Feldfehler und sichtbaren Fokus prüfen.
Auf 320 Pixel Breite und mit Browser-Zoom prüfen; ergänzend Screenreader verwenden.
7. Für den Versandfehler einen neuen Test in neuer Sitzung bei gestopptem Mailpit
ausführen: Buchung bleibt gespeichert, Anzeige meldet keine erfolgreiche Zustellung.
**Das Akzeptanzkriterium „E-Mail zugestellt“ ist mit SMTP-Annahme oder Mailpit allein
noch nicht für einen echten Empfänger nachgewiesen.** Erst nach ausdrücklicher
Freigabe echtes SMTP konfigurieren und an eine vereinbarte Testadresse senden.
Dann im tatsächlichen Empfängerpostfach (auch Spam) Eingang, Buchungsnummer und
Paket kontrollieren und Zeitpunkt sowie Ergebnis im Sprint-Nachweis festhalten.
Es gibt bisher keine automatische Bounce-/Zustellverfolgung.
## Durchgeführte Prüfungen
- 17 Backend-Tests: Validierung, CSV-Sonderzeichen/Formelschutz, parallele
Anfragen, Wiederholung mit neuer Serviceinstanz, Speicher- und SMTP-Fehler.
- 6 Chromium-Prüfungen: Tastaturbedienung/Fokus, 320-Pixel-Ansicht, verlorene
Antwort mit Neuladen, Ladezustand, Versandfehler und private Dateipfade.
- Lokaler SMTP-Test mit Mailpit und temporärer CSV: Nachricht tatsächlich im
Mailfänger empfangen, Paket/Buchungsnummer geprüft, wiederholte Anfrage ohne
zusätzliche CSV-Zeile oder E-Mail. Ausschließlich `example.test`-Empfänger.
- Projektweite Formatprüfung, Produktionsbuild und Git-Whitespace-Prüfung.
Die automatisierten Prüfungen ersetzen keine Screenreader-Abnahme oder Prüfung
in weiteren Browsern. Keine echte E-Mail-Zustellung wurde getestet. Zum Ausführen
der Browserprüfungen wurden Chromium und die benötigten Systembibliotheken in
dieser Arbeitsumgebung installiert; die Dev-Container-Konfigurationsdatei wurde
nicht verändert. Der Mailpit-Test verwendete eine temporär heruntergeladene
Binärdatei außerhalb des Repositorys.
## Offene Punkte und gemeinsame Dateien
Offen: echte Paketdaten, Anschluss an den gemeinsamen Router, abschließendes
Teamdesign, reale Zustellprüfung und Screenreader-Abnahme. Vor öffentlichem
Betrieb sind außerdem Betriebsfragen wie Zugriff auf CSV/Backups, Aufbewahrung
und Schutz des öffentlichen Buchungsendpunkts vor automatisiertem Massenversand
zu klären. Keine Zahlungen, Benutzerkonten oder Datenbank implementiert.
Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefehle),
`src/main.js` (bestehenden falschen CSS-Import korrigiert), `.gitignore` und
`.prettierignore` (private Daten/Testergebnisse ausschließen). Neu hinzugefügt:
`vite.config.js` (Proxy, Dateizugriffsschutz, zwei HTML-Einstiegspunkte),
`src/shared/packages.js` (gemeinsamer Paketvertrag), `.env.example`, `booking.html`
und `playwright.config.js`. Die Dev-Container-Konfiguration bleibt unverändert.
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).

72
package-lock.json generated
View File

@ -7,7 +7,13 @@
"": {
"name": "ernaehrungsberatung",
"version": "0.1.0",
"dependencies": {
"csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3",
"nodemailer": "^10.0.10"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"prettier": "^3.6.2",
"vite": "^7.1.7"
},
@ -477,6 +483,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
@ -873,6 +895,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
"integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
"license": "MIT"
},
"node_modules/csv-stringify": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz",
"integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@ -967,6 +1001,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -987,6 +1030,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",

View File

@ -11,10 +11,20 @@
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173 --strictPort",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"dev:server": "node --env-file-if-exists=.env --watch src/server/index.js",
"start:server": "node --env-file-if-exists=.env src/server/index.js",
"test": "node --test src/tests/bookings.test.js",
"test:browser": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"prettier": "^3.6.2",
"vite": "^7.1.7"
},
"dependencies": {
"csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3",
"nodemailer": "^10.0.10"
}
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./src/tests/browser",
workers: 1,
use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" },
webServer: [
{
command: "node src/tests/helpers/browser-server.js",
url: "http://127.0.0.1:3000/api/angebote",
reuseExistingServer: false,
gracefulShutdown: { signal: "SIGTERM", timeout: 3000 },
},
{
command: "npm run dev",
url: "http://127.0.0.1:5173/booking.html",
reuseExistingServer: false,
},
],
});

View File

View File

@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() {
</div>
<!-- HIER ist der wichtige Link für deinen Kollegen: -->
<a href="buchung.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
<a href="booking.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
</div>
`;

View File

@ -0,0 +1,49 @@
export class BookingApiError extends Error {
constructor(message, code, fields = {}) {
super(message);
this.code = code;
this.fields = fields;
}
}
async function request(path, options = {}) {
let response;
let body;
try {
response = await fetch(path, {
...options,
signal: options.signal ?? AbortSignal.timeout(45000),
});
body = await response.json();
} catch (error) {
if (options.signal?.aborted) throw error;
throw new BookingApiError(
"Die Verbindung ist unterbrochen oder der Server nicht erreichbar. Deine Angaben bleiben erhalten. Bitte den Status mit derselben Anfrage erneut prüfen.",
"NETWORK_ERROR",
);
}
if (!response.ok) {
throw new BookingApiError(
body.error?.message ||
"Die Anfrage konnte nicht verarbeitet werden.",
body.error?.code,
body.error?.fields,
);
}
return body;
}
export function loadPackages(signal) {
return request("/api/angebote", { signal });
}
export function submitBooking(payload, idempotencyKey) {
return request("/api/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
}

View File

@ -0,0 +1,13 @@
import "../../styles/styles.css";
import { mountBookingPage } from "./booking-page.js";
const root = document.querySelector("#booking-root");
let cleanup;
function render() {
cleanup?.();
// Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
cleanup = mountBookingPage(root, { packageId: match?.[1] });
}
window.addEventListener("hashchange", render);
render();

View File

@ -0,0 +1,276 @@
import { loadPackages, submitBooking } from "./booking-api.js";
import "./booking.css";
import { paymentNotice } from "../../shared/booking-copy.js";
// Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt
// Listener und bricht das Laden beim Verlassen der Ansicht ab.
export function mountBookingPage(root, { packageId } = {}) {
const controller = new AbortController();
const { signal } = controller;
root.innerHTML = `
<div class="booking">
<a href="/#beratung">Zur Angebotsseite</a>
<h1>Paket buchen</h1>
<p class="booking__intro" role="status">Paket wird geladen …</p>
<div class="booking__content"></div>
</div>`;
const intro = root.querySelector(".booking__intro");
const content = root.querySelector(".booking__content");
async function load() {
if (!packageId) {
intro.textContent =
"Bitte wähle zuerst ein Paket auf der Angebotsseite aus.";
return;
}
try {
const data = await loadPackages(signal);
if (signal.aborted) return;
const selected = data.packages.find(
(entry) => entry.id === packageId,
);
if (!selected) {
intro.textContent =
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.";
return;
}
intro.remove();
renderForm(selected);
} catch {
if (signal.aborted) return;
intro.textContent =
"Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut.";
const retry = document.createElement("button");
retry.className = "booking__button";
retry.textContent = "Paket erneut laden";
retry.addEventListener(
"click",
() => {
retry.remove();
intro.textContent = "Paket wird geladen …";
load();
},
{ once: true, signal },
);
content.replaceChildren(retry);
}
}
function renderForm(selected) {
content.innerHTML = `
<div class="booking__package">
<h2 class="booking__package-name"></h2>
<p class="booking__summary"></p>
</div>
<aside class="booking__payment" aria-labelledby="booking-payment-heading">
<h2 id="booking-payment-heading">Hinweis zum Buchungsablauf</h2>
<p class="booking__payment-notice"></p>
</aside>
<form novalidate>
<p>Alle Felder sind Pflichtfelder.</p>
<div class="booking__errors" role="alert" tabindex="-1" hidden></div>
<div class="booking__field">
<label for="booking-name">Name (Pflichtfeld)</label>
<input id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="booking-name-error" />
<p class="booking__field-error" id="booking-name-error"></p>
</div>
<div class="booking__field">
<label for="booking-email">E-Mail-Adresse (Pflichtfeld)</label>
<input id="booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="booking-email-hint booking-email-error" />
<p id="booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p>
<p class="booking__field-error" id="booking-email-error"></p>
</div>
<button class="booking__button" type="submit">Paket buchen</button>
<p class="booking__progress" role="status"></p>
</form>
<div class="booking__result" role="status" tabindex="-1" hidden></div>`;
content.querySelector(".booking__package-name").textContent =
selected.name;
content.querySelector(".booking__summary").textContent =
selected.summary;
content.querySelector(".booking__payment-notice").textContent =
paymentNotice;
const form = content.querySelector("form");
const name = form.elements.namedItem("name");
const email = form.elements.namedItem("email");
const button = form.querySelector("button");
const errors = form.querySelector(".booking__errors");
const progress = form.querySelector(".booking__progress");
const result = content.querySelector(".booking__result");
const storageKey = `trihub.booking.${selected.id}`;
let attempt = null;
let busy = false;
let finished = false;
function lockFields(locked) {
name.readOnly = locked;
email.readOnly = locked;
}
function showError(message, fields = {}) {
errors.textContent = message;
errors.hidden = false;
for (const field of [name, email]) {
const message = fields[field.name] || "";
content.querySelector(
`#booking-${field.name}-error`,
).textContent = message;
field.setAttribute("aria-invalid", String(Boolean(message)));
}
errors.focus();
}
function clearErrors() {
errors.hidden = true;
for (const field of [name, email]) {
field.removeAttribute("aria-invalid");
content.querySelector(
`#booking-${field.name}-error`,
).textContent = "";
}
}
function showResult(body, focus = true) {
const messages = {
accepted:
"Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.",
failed: "Buchung gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
pending:
"Buchung gespeichert. Der Bestätigungsversand konnte noch nicht gestartet werden. Du kannst den Versand mit der bestehenden Buchung erneut versuchen.",
unknown:
"Buchung gespeichert. Der Versandstatus der Bestätigung ist unklar. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
};
result.textContent = `${messages[body.emailStatus] || messages.unknown} Buchungsnummer: ${body.bookingId}`;
result.hidden = false;
lockFields(true);
finished = body.emailStatus !== "pending";
button.disabled = finished;
button.textContent = finished
? "Buchung gespeichert"
: "Bestätigungsversand erneut versuchen";
if (focus) result.focus();
}
try {
const stored = JSON.parse(sessionStorage.getItem(storageKey));
if (stored?.key && stored.payload?.packageId === selected.id) {
attempt = stored;
name.value = stored.payload.name;
email.value = stored.payload.email;
lockFields(true);
button.textContent = "Status prüfen / erneut versuchen";
if (stored.result) showResult(stored.result, false);
else
progress.textContent =
"Eine frühere Anfrage ist noch offen. Bitte ihren Status prüfen, bevor du erneut buchst.";
}
} catch {
// Speichern wird vor der ersten Anfrage zwingend erneut geprüft.
}
form.addEventListener(
"submit",
async (event) => {
event.preventDefault();
if (busy || finished) return;
clearErrors();
if (!attempt) {
const fields = {};
name.value = name.value.trim();
email.value = email.value.trim();
if (
!name.value ||
name.value.length > 120 ||
/[\x00-\x1f\x7f]/.test(name.value)
)
fields.name =
"Bitte deinen Namen eingeben (höchstens 120 Zeichen, ohne Zeilenumbrüche).";
if (!email.validity.valid || email.value.length > 254)
fields.email =
"Bitte eine gültige E-Mail-Adresse eingeben.";
if (Object.keys(fields).length) {
showError(
"Bitte die markierten Angaben prüfen.",
fields,
);
return;
}
const nextAttempt = {
key: crypto.randomUUID(),
payload: {
packageId: selected.id,
name: name.value,
email: email.value,
},
};
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(nextAttempt),
);
} catch {
showError(
"Der Browser kann die Anfrage nicht für einen sicheren erneuten Versuch merken. Bitte erlaube den Sitzungsspeicher und versuche es erneut.",
);
return;
}
attempt = nextAttempt;
}
busy = true;
button.disabled = true;
button.textContent = "Buchung wird verarbeitet …";
form.setAttribute("aria-busy", "true");
lockFields(true);
progress.textContent =
"Bitte warten. Deine Buchung wird geprüft und gespeichert.";
try {
const body = await submitBooking(
attempt.payload,
attempt.key,
);
attempt.result = body;
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(attempt),
);
} catch {
/* Der ursprüngliche Schlüssel bleibt erhalten. */
}
if (!signal.aborted) showResult(body);
} catch (error) {
// Nur eindeutige Ablehnungen vor dem Speichern erlauben eine
// Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest.
if (
[
"INVALID_INPUT",
"UNKNOWN_PACKAGE",
"INVALID_KEY",
].includes(error.code)
) {
attempt = null;
try {
sessionStorage.removeItem(storageKey);
} catch {
/* Keine Anfrage gespeichert. */
}
lockFields(false);
}
if (!signal.aborted) {
showError(error.message, error.fields);
button.textContent = attempt
? "Status prüfen / erneut versuchen"
: "Paket buchen";
}
} finally {
busy = false;
if (!signal.aborted) {
form.removeAttribute("aria-busy");
progress.textContent = "";
button.disabled = finished;
}
}
},
{ signal },
);
}
load();
return () => controller.abort();
}

View File

@ -0,0 +1,96 @@
.booking {
max-width: 42rem;
margin-inline: auto;
overflow-wrap: anywhere;
}
.booking h1 {
max-width: none;
}
.booking__package,
.booking__result {
padding: 1.25rem;
border: 1px solid currentColor;
border-radius: 0.5rem;
margin-block: 1.5rem;
}
.booking__package h2 {
margin-top: 0;
}
.booking__payment {
margin-block: 1.5rem;
padding-left: 1rem;
border-left: 3px solid currentColor;
}
.booking__payment h2 {
font-size: 1.1rem;
}
.booking__field {
margin-block: 1.5rem;
}
.booking label {
display: block;
font-weight: 600;
margin-bottom: 0.35rem;
}
.booking input,
.booking__button {
font: inherit;
border: 1px solid currentColor;
border-radius: 0.25rem;
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
}
.booking input {
width: 100%;
color: inherit;
background: transparent;
}
.booking input[readonly] {
border-style: dashed;
}
.booking__button {
color: #f7faf7;
background: #176448;
cursor: pointer;
max-width: 100%;
}
.booking__button:disabled {
opacity: 0.7;
cursor: default;
}
.booking :focus-visible,
.booking__errors:focus,
.booking__result:focus {
outline: 3px solid currentColor;
outline-offset: 4px;
}
.booking__button:focus-visible {
outline-color: #18352b;
}
.booking__errors,
.booking__field-error {
color: #8b1e1e;
}
.booking__errors {
padding: 0.8rem;
border: 2px solid currentColor;
margin-block: 1rem;
}
.booking__field p {
margin-block: 0.35rem;
}
.booking__field-error:empty {
display: none;
}
.booking [aria-invalid="true"] {
border: 2px solid #8b1e1e;
}
@media (max-width: 30rem) {
.booking__button {
width: 100%;
}
}

View File

@ -1,3 +1,3 @@
import "./styles.css";
import "./styles/styles.css";
document.querySelector("#year").textContent = new Date().getFullYear();

View File

@ -0,0 +1,87 @@
import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url";
import { packages, demoPackages } from "../shared/packages.js";
import { createBookingService } from "./services/booking-service.js";
import { createCsvStorage } from "./services/csv-storage.js";
import { createEmailService } from "./services/email-service.js";
import { handleBooking, json } from "./routes/bookings.js";
export function createApp({
catalog = packages,
storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
),
sendConfirmation,
} = {}) {
const ids = new Set();
for (const entry of catalog) {
if (
!entry ||
typeof entry.id !== "string" ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) ||
entry.id.length > 80 ||
ids.has(entry.id) ||
typeof entry.name !== "string" ||
!entry.name.trim() ||
typeof entry.summary !== "string" ||
!entry.summary.trim()
) {
throw new Error(
"Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.",
);
}
ids.add(entry.id);
}
const service = createBookingService({
storage,
packages: catalog,
sendConfirmation: sendConfirmation ?? createEmailService(),
});
return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/angebote" && request.method === "GET") {
return json(response, 200, {
packages: catalog.map(({ id, name, summary, testOnly }) => ({
id,
name,
summary,
testOnly: Boolean(testOnly),
})),
});
}
if (path === "/api/bookings") {
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return json(response, 405, {
error: {
code: "METHOD_NOT_ALLOWED",
message: "Bitte POST verwenden.",
},
});
}
return handleBooking(request, response, service);
}
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, {
error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." },
});
});
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const demo = process.env.BOOKING_DEMO === "true";
if (demo && process.env.NODE_ENV === "production")
throw new Error("Demo-Modus ist in Produktion gesperrt.");
const port = Number(process.env.PORT || 3000);
const server = createApp({
catalog: demo ? [...packages, ...demoPackages] : packages,
});
server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(
`Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`,
);
});
}

View File

@ -0,0 +1,75 @@
import { BookingError } from "../services/booking-service.js";
export function json(response, status, body) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
response.end(JSON.stringify(body));
}
async function readJson(request) {
if (
request.headers["content-type"]?.split(";")[0].trim() !==
"application/json"
) {
throw new BookingError(
415,
"UNSUPPORTED_CONTENT_TYPE",
"Bitte JSON senden.",
);
}
let size = 0;
const chunks = [];
for await (const chunk of request) {
size += chunk.length;
if (size > 8192)
throw new BookingError(
413,
"BODY_TOO_LARGE",
"Die Anfrage ist zu groß.",
);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new BookingError(
400,
"INVALID_JSON",
"Die Anfrage enthält kein gültiges JSON.",
);
}
}
export async function handleBooking(request, response, service) {
try {
const input = await readJson(request);
const result = await service.book(
input,
request.headers["idempotency-key"],
);
json(response, result.status, result.body);
} catch (error) {
if (error instanceof BookingError) {
json(response, error.status, {
error: {
code: error.code,
message: error.message,
fields: error.fields,
},
});
} else {
// Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben.
console.error("Buchungsanfrage unerwartet fehlgeschlagen.");
json(response, 500, {
error: {
code: "INTERNAL_ERROR",
message:
"Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.",
},
});
}
}
}

View File

@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
export class BookingError extends Error {
constructor(status, code, message, fields = {}) {
super(message);
this.status = status;
this.code = code;
this.fields = fields;
}
}
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen.
const emailPattern =
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
function validateInput(input, key) {
if (!uuidPattern.test(key ?? "")) {
throw new BookingError(
400,
"INVALID_KEY",
"Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.",
);
}
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError(
400,
"INVALID_INPUT",
"Erwartet wird ein JSON-Objekt.",
);
}
const fields = {};
const result = {};
for (const [field, max, message] of [
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
[
"email",
254,
"Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).",
],
]) {
const value = input[field];
if (typeof value !== "string" || !value.trim() || value.length > max) {
fields[field] = message;
} else {
result[field] = value.trim();
}
}
if (
result.packageId &&
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId)
) {
fields.packageId = "Die Paket-ID ist ungültig.";
}
if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) {
fields.name =
"Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben.";
}
if (
result.email &&
(!emailPattern.test(result.email) ||
result.email.split("@")[0].length > 64)
) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
}
if (Object.keys(fields).length) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte die markierten Angaben prüfen.",
fields,
);
}
return result;
}
// Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende
// Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten.
function nextBookingId(records) {
let highest = 0n;
for (const record of records) {
const match = /^TH-([0-9]{6,})$/.exec(record.bookingId);
if (match) {
const number = BigInt(match[1]);
if (number > highest) highest = number;
}
}
return `TH-${String(highest + 1n).padStart(6, "0")}`;
}
function responseFor(record, replayed) {
// Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar.
const emailStatus =
record.emailStatus === "sending" ? "unknown" : record.emailStatus;
return {
status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202,
body: {
bookingId: record.bookingId,
createdAt: record.createdAt,
packageId: record.packageId,
packageName: record.packageName,
saved: true,
emailStatus,
replayed,
},
};
}
export function createBookingService({ storage, sendConfirmation, packages }) {
// Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess!
let queue = Promise.resolve();
async function processBooking(input, key) {
const data = validateInput(input, key);
const requestHash = createHash("sha256")
.update(JSON.stringify(data))
.digest("hex");
let records;
try {
records = await storage.readAll();
} catch {
throw new BookingError(
503,
"STORAGE_UNAVAILABLE",
"Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.",
);
}
let record = records.find((entry) => entry.idempotencyKey === key);
const replayed = Boolean(record);
if (record && record.requestHash !== requestHash) {
throw new BookingError(
409,
"IDEMPOTENCY_CONFLICT",
"Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.",
);
}
if (record && record.emailStatus !== "pending")
return responseFor(record, true);
if (!record) {
const selected = packages.find(
(entry) => entry.id === data.packageId,
);
if (!selected) {
throw new BookingError(
400,
"UNKNOWN_PACKAGE",
"Das ausgewählte Paket ist nicht verfügbar.",
{ packageId: "Bitte ein verfügbares Paket auswählen." },
);
}
record = {
bookingId: nextBookingId(records),
createdAt: new Date().toISOString(),
packageId: selected.id,
packageName: selected.name,
name: data.name,
email: data.email,
emailStatus: "pending",
idempotencyKey: key,
requestHash,
};
records.push(record);
try {
await storage.writeAll(records);
} catch {
throw new BookingError(
503,
"BOOKING_NOT_SAVED",
"Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.",
);
}
}
// Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind
// noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion.
record.emailStatus = "sending";
try {
await storage.writeAll(records);
} catch {
record.emailStatus = "pending";
return responseFor(record, replayed);
}
try {
await sendConfirmation(record);
record.emailStatus = "accepted";
} catch (error) {
record.emailStatus = error.deliveryUnknown ? "unknown" : "failed";
}
try {
await storage.writeAll(records);
} catch {
record.emailStatus = "unknown";
}
return responseFor(record, replayed);
}
return {
book(input, key) {
const task = queue.then(() => processBooking(input, key));
queue = task.catch(() => {});
return task;
},
};
}

View File

@ -0,0 +1,88 @@
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
export const columns = [
"bookingId",
"createdAt",
"packageId",
"packageName",
"name",
"email",
"emailStatus",
"idempotencyKey",
"requestHash",
];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
function protectCell(value) {
const text = String(value ?? "");
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
}
function restoreCell(value) {
return value.startsWith("'") ? value.slice(1) : value;
}
export function createCsvStorage(filePath) {
return {
async readAll() {
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
if (!content.trim()) throw new Error("Leere Buchungsdatei");
return parse(content, {
bom: true,
columns(header) {
if (header.join(",") !== columns.join(",")) {
throw new Error("Unbekanntes CSV-Format");
}
return header;
},
skip_empty_lines: true,
}).map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
restoreCell(value),
]),
),
);
},
async writeAll(records) {
await mkdir(dirname(filePath), { recursive: true });
const temporary = `${filePath}.${randomUUID()}.tmp`;
const content = stringify(
records.map((row) =>
Object.fromEntries(
columns.map((column) => [
column,
protectCell(row[column]),
]),
),
),
{ header: true, columns, record_delimiter: "\r\n" },
);
try {
const handle = await open(temporary, "wx", 0o600);
try {
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
// Atomarer Austausch im selben Verzeichnis: keine halben Zeilen.
await rename(temporary, filePath);
} finally {
await rm(temporary, { force: true });
}
},
};
}

View File

@ -0,0 +1,74 @@
import nodemailer from "nodemailer";
import { paymentNotice } from "../../shared/booking-copy.js";
export function createEmailService(
env = process.env,
makeTransport = nodemailer.createTransport,
) {
const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"];
const host = env.SMTP_HOST || "127.0.0.1";
const local = localHosts.includes(host);
if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") {
throw new Error(
"Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.",
);
}
const port = Number(env.SMTP_PORT || (local ? 1025 : 587));
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("Ungültiger SMTP_PORT");
if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS))
throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen");
const transport = makeTransport({
host,
port,
secure: env.SMTP_SECURE === "true",
requireTLS: !local,
auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASS }
: undefined,
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 20000,
disableFileAccess: true,
disableUrlAccess: true,
});
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return async function sendConfirmation(booking) {
let info;
try {
info = await transport.sendMail({
from,
to: { address: booking.email, name: booking.name },
subject: `Buchungsbestätigung ${booking.bookingId}`,
text: [
`Hallo ${booking.name},`,
"",
"vielen Dank für deine Buchung. Wir haben deine Angaben erhalten.",
`Buchungsnummer: ${booking.bookingId}`,
`Paket: ${booking.packageName}`,
"",
paymentNotice,
"",
"Dein Tri-Hub-Team",
].join("\n"),
});
} catch (error) {
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen
// haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler.
const explicitRejection =
["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes(
error.code,
) || Number(error.responseCode) >= 400;
error.deliveryUnknown = !explicitRejection;
throw error;
}
if (
!info.accepted?.some(
(address) =>
address.toLowerCase() === booking.email.toLowerCase(),
)
) {
throw new Error("Empfänger vom Mailserver nicht angenommen");
}
};
}

View File

@ -0,0 +1,4 @@
// Gemeinsamer Hinweis für Buchungsseite und Bestätigungs-E-Mail.
// Bei der späteren Integration echter Zahlungen zusammen mit dem Ablauf ändern.
export const paymentNotice =
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung. Bei der vollständigen Integration des Bestellprozesses kann die Zahlung ergänzt werden.";

View File

@ -0,0 +1,427 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js";
import { createBookingService } from "../server/services/booking-service.js";
import { createEmailService } from "../server/services/email-service.js";
import { demoPackages } from "../shared/packages.js";
const input = {
packageId: demoPackages[0].id,
name: 'Test, "Person"',
email: "person@example.test",
};
async function setup(t, options = {}) {
const directory = await mkdtemp(join(tmpdir(), "trihub-booking-"));
const file = join(directory, "bookings.csv");
const storage = createCsvStorage(file);
const sent = [];
const sendConfirmation =
options.sendConfirmation ??
(async (booking) => {
sent.push({ ...booking });
});
const dependencies = {
catalog: demoPackages,
storage,
sendConfirmation,
...options,
};
const app = createApp(dependencies);
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(async () => {
app.closeAllConnections();
await new Promise((resolve) => app.close(resolve));
await rm(directory, { recursive: true, force: true });
});
const url = `http://127.0.0.1:${app.address().port}`;
async function post(body = input, key = randomUUID()) {
const response = await fetch(`${url}/api/bookings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
return { status: response.status, body: await response.json() };
}
return { post, url, file, storage, sent, dependencies };
}
test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) => {
const fixture = await setup(t);
const result = await fixture.post({
...input,
packageName: "Manipuliert",
price: 0,
});
assert.equal(result.status, 201);
assert.equal(result.body.emailStatus, "accepted");
assert.equal(result.body.packageName, demoPackages[0].name);
assert.equal(result.body.bookingId, "TH-000001");
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
const records = await fixture.storage.readAll();
assert.equal(records.length, 1);
assert.equal(records[0].name, input.name);
assert.equal(records[0].emailStatus, "accepted");
assert.equal(fixture.sent.length, 1);
assert.equal(fixture.sent[0].emailStatus, "sending");
assert.ok(!("email" in result.body));
});
test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt", async (t) => {
const { post, storage, sent } = await setup(t);
for (const body of [
null,
[],
{},
{ ...input, name: " " },
{ ...input, name: 42 },
{ ...input, name: "a".repeat(121) },
{ ...input, name: "Test\nBcc: fremd" },
{ ...input, email: "keine-adresse" },
{ ...input, email: "a..b@example.test" },
{ ...input, email: ".person@example.test" },
{ ...input, email: "person.@example.test" },
{ ...input, email: "person@example..test" },
{ ...input, email: "person@-example.test" },
{ ...input, email: `person@${"a".repeat(64)}.test` },
{ ...input, email: "a\r\nb@example.test" },
{ ...input, email: false },
{ ...input, email: "a".repeat(255) },
{ ...input, packageId: "unknown" },
{ ...input, packageId: "../demo" },
{ ...input, packageId: 1 },
{ ...input, packageId: "a".repeat(81) },
]) {
const result = await post(body);
assert.equal(result.status, 400, JSON.stringify(body));
}
assert.equal((await post(input, "invalid")).status, 400);
assert.deepEqual(await storage.readAll(), []);
assert.equal(sent.length, 0);
});
test("Parallele Wiederholungen und Neustart erzeugen nur eine Buchung und Mail", async (t) => {
const { post, storage, sent, dependencies } = await setup(t);
const key = randomUUID();
const results = await Promise.all(
Array.from({ length: 8 }, () => post(input, key)),
);
assert.equal(new Set(results.map((r) => r.body.bookingId)).size, 1);
assert.equal(results.filter((r) => r.status === 201).length, 1);
assert.equal(sent.length, 1);
assert.equal((await storage.readAll()).length, 1);
// Neue Serviceinstanz liest ausschließlich den persistenten CSV-Zustand.
const restarted = createBookingService({ ...dependencies, packages: [] });
const replay = await restarted.book(input, key);
assert.equal(replay.status, 200);
assert.equal(replay.body.bookingId, results[0].body.bookingId);
assert.equal(sent.length, 1);
const conflict = await post({ ...input, name: "Andere Person" }, key);
assert.equal(conflict.status, 409);
});
test("Verschiedene gleichzeitige Buchungen behalten alle CSV-Zeilen", async (t) => {
const { post, storage } = await setup(t);
const results = await Promise.all(
Array.from({ length: 12 }, (_, i) =>
post({ ...input, name: `Person ${i}` }),
),
);
assert.ok(results.every((r) => r.status === 201));
const records = await storage.readAll();
assert.equal(records.length, 12);
assert.deepEqual(
records.map((record) => record.bookingId),
Array.from(
{ length: 12 },
(_, i) => `TH-${String(i + 1).padStart(6, "0")}`,
),
);
});
test("CSV schützt Formeln und erhält Kommas, Anführungszeichen, Apostrophe und Zeilenumbrüche", async (t) => {
const { storage, file } = await setup(t);
const values = [
'=HYPERLINK("evil")',
"+1",
"-1",
"@SUM(A1)",
"\t=1",
"\r=1",
"\n=1",
" =1",
"'Original",
'Test, "Name"\nzweite Zeile',
];
const rows = values.map((name) => ({ name, packageName: name }));
await storage.writeAll(rows);
assert.deepEqual(
(await storage.readAll()).map((r) => r.name),
values,
);
const text = await readFile(file, "utf8");
assert.ok(text.includes("'=HYPERLINK"));
assert.ok(text.includes("''Original"));
assert.ok(text.includes('"Test, ""Name""\nzweite Zeile"'));
await storage.writeAll(await storage.readAll());
assert.deepEqual(
(await storage.readAll()).map((r) => r.name),
values,
);
});
test("Fehlgeschlagenes erstes Schreiben verhindert Versand", async (t) => {
let sent = 0;
const { post } = await setup(t, {
storage: {
readAll: async () => [],
writeAll: async () => {
throw new Error("Disk full");
},
},
sendConfirmation: async () => {
sent++;
},
});
const result = await post();
assert.equal(result.status, 503);
assert.equal(result.body.error.code, "BOOKING_NOT_SAVED");
assert.equal(sent, 0);
});
test("Beschädigte CSV wird nicht überschrieben", async (t) => {
const { post, file, sent } = await setup(t);
await writeFile(file, "falscher,header\n1,2\n");
const result = await post();
assert.equal(result.status, 503);
assert.equal(result.body.error.code, "STORAGE_UNAVAILABLE");
assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n");
assert.equal(sent.length, 0);
});
test("Mailfehler speichert Buchung; Wiederholung sendet nicht erneut", async (t) => {
let calls = 0;
const { post, storage } = await setup(t, {
sendConfirmation: async () => {
calls++;
throw new Error("SMTP rejected");
},
});
const key = randomUUID();
const first = await post(input, key);
assert.equal(first.status, 202);
assert.equal(first.body.saved, true);
assert.equal(first.body.emailStatus, "failed");
assert.equal((await storage.readAll())[0].emailStatus, "failed");
assert.equal((await post(input, key)).body.bookingId, first.body.bookingId);
assert.equal(calls, 1);
});
test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", async (t) => {
const { storage } = await setup(t);
let writes = 0;
let sends = 0;
const service = createBookingService({
packages: demoPackages,
storage: {
readAll: () => storage.readAll(),
writeAll: async (rows) => {
if (++writes === 2) throw new Error("Disk full");
return storage.writeAll(rows);
},
},
sendConfirmation: async () => {
sends++;
},
});
const key = randomUUID();
const first = await service.book(input, key);
assert.equal(first.body.emailStatus, "pending");
assert.equal(sends, 0);
const retry = await service.book(input, key);
assert.equal(retry.body.emailStatus, "accepted");
assert.equal(retry.body.bookingId, first.body.bookingId);
assert.equal(sends, 1);
});
test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversand aus", async (t) => {
const { storage } = await setup(t);
let writes = 0;
let sends = 0;
const service = createBookingService({
packages: demoPackages,
storage: {
readAll: () => storage.readAll(),
writeAll: async (rows) => {
if (++writes === 3) throw new Error("Disk full");
return storage.writeAll(rows);
},
},
sendConfirmation: async () => {
sends++;
},
});
const key = randomUUID();
assert.equal((await service.book(input, key)).body.emailStatus, "unknown");
assert.equal((await storage.readAll())[0].emailStatus, "sending");
const restarted = createBookingService({
storage,
packages: demoPackages,
sendConfirmation: async () => {
sends++;
},
});
assert.equal(
(await restarted.book(input, key)).body.emailStatus,
"unknown",
);
assert.equal(sends, 1);
});
test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind standardmäßig gesperrt", async () => {
let message;
const send = createEmailService({}, () => ({
sendMail: async (mail) => {
message = mail;
return { accepted: [input.email] };
},
}));
await send({
...input,
bookingId: "TH-000001",
packageName: demoPackages[0].name,
});
assert.ok(message.text.includes("TH-000001"));
assert.ok(message.text.includes(demoPackages[0].name));
assert.equal(message.to.address, input.email);
assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen"));
assert.ok(message.text.includes("keine Abbuchung"));
assert.throws(
() => createEmailService({ SMTP_HOST: "smtp.example.org" }),
/gesperrt/,
);
const rejected = createEmailService({}, () => ({
sendMail: async () => ({ accepted: [] }),
}));
await assert.rejects(() => rejected(input));
});
test("SMTP-Verbindungsabbruch unterscheidet sich von expliziter Ablehnung", async () => {
for (const [code, expected] of [
["ETIMEDOUT", true],
["EAUTH", false],
]) {
const send = createEmailService({}, () => ({
sendMail: async () => {
throw Object.assign(new Error("SMTP"), { code });
},
}));
await assert.rejects(
() => send(input),
(error) => error.deliveryUnknown === expected,
);
}
});
test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t) => {
const { url } = await setup(t);
const response = await fetch(`${url}/api/bookings`);
assert.equal(response.status, 405);
for (const path of [
"/src/server/data/bookings.csv",
"/.env",
"/data/bookings.csv",
]) {
assert.equal((await fetch(`${url}${path}`)).status, 404);
}
assert.equal(
(await fetch(`${url}/api/bookings`, { method: "POST", body: "{}" }))
.status,
415,
);
assert.equal(
(
await fetch(`${url}/api/bookings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{",
})
).status,
400,
);
assert.equal(
(
await fetch(`${url}/api/bookings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "a".repeat(9000) }),
})
).status,
413,
);
const catalog = await (await fetch(`${url}/api/angebote`)).json();
assert.deepEqual(catalog.packages, demoPackages);
assert.equal((await fetch(`${url}/api/packages`)).status, 404);
});
test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => {
for (const catalog of [
[null],
[{ ...demoPackages[0], id: 123 }],
[{ ...demoPackages[0], id: undefined }],
[demoPackages[0], demoPackages[0]],
]) {
assert.throws(
() => createApp({ catalog, sendConfirmation: async () => {} }),
/Paketdaten erfüllen/,
);
}
});
test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen verbrauchen keine Nummer", async (t) => {
const { storage, post } = await setup(t);
const key = randomUUID();
assert.equal((await post(input, key)).body.bookingId, "TH-000001");
assert.equal((await post(input, key)).body.bookingId, "TH-000001");
const restarted = createBookingService({
storage,
packages: demoPackages,
sendConfirmation: async () => {},
});
assert.equal(
(await restarted.book(input, randomUUID())).body.bookingId,
"TH-000002",
);
assert.equal((await storage.readAll()).length, 2);
});
test("Bestehende UUID-Buchungen bleiben unverändert und weiterhin abrufbar", async (t) => {
const { storage, post } = await setup(t);
const key = randomUUID();
await post(input, key);
const records = await storage.readAll();
const legacyId = randomUUID();
records[0].bookingId = legacyId;
await storage.writeAll(records);
assert.equal((await post(input, key)).body.bookingId, legacyId);
assert.equal((await post()).body.bookingId, "TH-000001");
assert.equal((await storage.readAll())[0].bookingId, legacyId);
});
test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs Stellen hinaus", async (t) => {
const { storage, post } = await setup(t);
await post();
await post();
const records = await storage.readAll();
records[0].bookingId = "TH-999999";
await storage.writeAll(records);
assert.equal((await post()).body.bookingId, "TH-1000000");
});

View File

@ -0,0 +1,181 @@
import { test, expect } from "@playwright/test";
const pageUrl = "/booking.html#/buchen/demo-booking";
async function fill(page) {
await page
.getByLabel("Name (Pflichtfeld)", { exact: true })
.fill("Test Person");
await page
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
.fill("person@example.test");
}
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
page,
}) => {
await page.goto(pageUrl);
await expect(
page.getByRole("heading", { name: "1:1-Ernährungsberatung" }),
).toBeVisible();
await expect(page.locator(".booking__payment-notice")).toContainText(
"Zahlungsvorgang wird übersprungen",
);
await expect(page.locator(".booking__payment-notice")).toContainText(
"keine Abbuchung",
);
await page.keyboard.press("Tab");
await expect(
page.getByRole("link", { name: "Zur Angebotsseite" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toBeFocused();
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Enter");
await expect(page.getByRole("alert")).toBeFocused();
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2);
await page.keyboard.press("Tab");
await page.keyboard.type("Test Person");
await page.keyboard.press("Tab");
await page.keyboard.type("person@example.test");
await page.keyboard.press("Tab");
const outline = await page
.getByRole("button", { name: "Paket buchen", exact: true })
.evaluate((element) => getComputedStyle(element).outlineStyle);
expect(outline).toBe("solid");
await page.keyboard.press("Enter");
await expect(page.locator(".booking__result")).toContainText(
/Buchungsnummer: TH-\d{6,}/,
);
await expect(page.locator(".booking__result")).toContainText(
"Zustellung ist noch nicht bestätigt",
);
await expect(
page.getByRole("button", { name: "Buchung gespeichert" }),
).toBeDisabled();
await page.reload();
await expect(page.locator(".booking__result")).toContainText(
/Buchungsnummer: TH-\d{6,}/,
);
});
test("Schmales Display: Formular ohne horizontalen Überlauf", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto(pageUrl);
await fill(page);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await expect(
page.getByRole("button", { name: "Paket buchen", exact: true }),
).toBeVisible();
});
test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchungsnummer", async ({
page,
}) => {
let firstKey;
let firstBooking;
let requests = 0;
await page.route("**/api/bookings", async (route) => {
requests++;
if (requests === 1) {
firstKey = route.request().headers()["idempotency-key"];
const response = await route.fetch();
firstBooking = (await response.json()).bookingId;
await route.abort("failed");
} else {
expect(route.request().headers()["idempotency-key"]).toBe(firstKey);
await route.continue();
}
});
await page.goto(pageUrl);
await fill(page);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
await expect(page.getByRole("alert")).toBeVisible();
await page.reload();
await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toHaveValue("Test Person");
await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toHaveAttribute("readonly", "");
await page
.getByRole("button", { name: "Status prüfen / erneut versuchen" })
.click();
await expect(page.locator(".booking__result")).toContainText(firstBooking);
expect(requests).toBe(2);
});
test("Ladezustand verhindert doppelte Klicks; Mailfehler behauptet keinen vollständigen Erfolg", async ({
page,
}) => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
await page.route("**/api/bookings", async (route) => {
await gate;
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({
saved: true,
bookingId: "test-mailfehler",
emailStatus: "failed",
}),
});
});
await page.goto(pageUrl);
await fill(page);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
await expect(
page.getByRole("button", { name: "Buchung wird verarbeitet …" }),
).toBeDisabled();
release();
await expect(page.locator(".booking__result")).toContainText(
"Bestätigungsversand ist fehlgeschlagen",
);
await expect(page.locator(".booking__result")).toContainText(
"Bitte nicht erneut buchen",
);
await expect(
page.getByRole("button", { name: "Buchung gespeichert" }),
).toBeDisabled();
});
test("Fehlende und unbekannte Paket-ID zeigen kein Formular", async ({
page,
}) => {
await page.goto("/booking.html");
await expect(page.getByText("Bitte wähle zuerst ein Paket")).toBeVisible();
await expect(page.locator("form")).toHaveCount(0);
await page.goto("/booking.html#/buchen/unbekannt");
await expect(
page.getByText("Dieses Paket ist nicht verfügbar"),
).toBeVisible();
await expect(page.locator("form")).toHaveCount(0);
});
test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
request,
}) => {
for (const path of [
"/src/server/index.js",
"/src/server/data/.gitkeep",
"/.env.example",
]) {
const response = await request.get(path);
expect([403, 404]).toContain(response.status());
}
});

View File

@ -0,0 +1,23 @@
// Ausschließlich Testserver: temporäre CSV und simulierter Versand.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.js";
import { demoPackages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({
catalog: demoPackages,
storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {},
});
server.listen(3000, "127.0.0.1");
async function stop() {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await rm(directory, { recursive: true, force: true });
process.exit(0);
}
process.on("SIGTERM", stop);
process.on("SIGINT", stop);

29
vite.config.js 100644
View File

@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
export default defineConfig({
server: {
proxy: { "/api": "http://127.0.0.1:3000" },
fs: {
// Auch Vites direkten Dateizugriff auf das Projekt absichern.
deny: [
".env",
".env.*",
"*.{crt,pem}",
"**/.git/**",
"**/server/**",
"**/tests/**",
],
},
},
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
booking: fileURLToPath(
new URL("./booking.html", import.meta.url),
),
},
},
},
});