diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2968da5 --- /dev/null +++ b/.env.example @@ -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 " +# Für echtes SMTP erst nach ausdrücklicher Freigabe ändern. +SMTP_ALLOW_EXTERNAL=false diff --git a/.gitignore b/.gitignore index cc9ffb7..e872025 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.prettierignore b/.prettierignore index 320c107..790904b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,7 @@ node_modules/ dist/ package-lock.json + +src/server/data/ +playwright-report/ +test-results/ diff --git a/booking.html b/booking.html new file mode 100644 index 0000000..3a92cba --- /dev/null +++ b/booking.html @@ -0,0 +1,16 @@ + + + + + + + Paket buchen – Tri-Hub + + +
+ + + diff --git a/docs/booking.md b/docs/booking.md new file mode 100644 index 0000000..5f0541d --- /dev/null +++ b/docs/booking.md @@ -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: . +Mailfänger: . 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 ` | 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). diff --git a/package-lock.json b/package-lock.json index d186ecb..7e92738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f8e5685..2af1f0a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..892f592 --- /dev/null +++ b/playwright.config.js @@ -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, + }, + ], +}); diff --git a/server/routes/.gitkeep b/server/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server/services/.gitkeep b/server/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/booking/booking-api.js b/src/features/booking/booking-api.js new file mode 100644 index 0000000..65d8613 --- /dev/null +++ b/src/features/booking/booking-api.js @@ -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), + }); +} diff --git a/src/features/booking/booking-entry.js b/src/features/booking/booking-entry.js new file mode 100644 index 0000000..81d6433 --- /dev/null +++ b/src/features/booking/booking-entry.js @@ -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(); diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js new file mode 100644 index 0000000..e15a656 --- /dev/null +++ b/src/features/booking/booking-page.js @@ -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 = ` +
+ Zur Angebotsseite +

Paket buchen

+

Paket wird geladen …

+
+
`; + 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 = ` +
+

+

+
+ +
+

Alle Felder sind Pflichtfelder.

+ +
+ + +

+
+
+ + +

An diese Adresse senden wir deine Bestätigung.

+

+
+ +

+
+ `; + 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(); +} diff --git a/src/features/booking/booking.css b/src/features/booking/booking.css new file mode 100644 index 0000000..0c99b24 --- /dev/null +++ b/src/features/booking/booking.css @@ -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%; + } +} diff --git a/src/main.js b/src/main.js index cb01bb8..95efe73 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,3 @@ -import "./styles.css"; +import "./styles/styles.css"; document.querySelector("#year").textContent = new Date().getFullYear(); diff --git a/server/data/.gitkeep b/src/server/data/.gitkeep similarity index 100% rename from server/data/.gitkeep rename to src/server/data/.gitkeep diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..8fb4c7e --- /dev/null +++ b/src/server/index.js @@ -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)" : ""}`, + ); + }); +} diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js new file mode 100644 index 0000000..f9f80fd --- /dev/null +++ b/src/server/routes/bookings.js @@ -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.", + }, + }); + } + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js new file mode 100644 index 0000000..bb32272 --- /dev/null +++ b/src/server/services/booking-service.js @@ -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; + }, + }; +} diff --git a/src/server/services/csv-storage.js b/src/server/services/csv-storage.js new file mode 100644 index 0000000..978d146 --- /dev/null +++ b/src/server/services/csv-storage.js @@ -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 }); + } + }, + }; +} diff --git a/src/server/services/email-service.js b/src/server/services/email-service.js new file mode 100644 index 0000000..5b2f00e --- /dev/null +++ b/src/server/services/email-service.js @@ -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 "; + 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"); + } + }; +} diff --git a/src/shared/booking-copy.js b/src/shared/booking-copy.js new file mode 100644 index 0000000..3fdac03 --- /dev/null +++ b/src/shared/booking-copy.js @@ -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."; diff --git a/src/shared/packages.js b/src/shared/packages.js new file mode 100644 index 0000000..1305cad --- /dev/null +++ b/src/shared/packages.js @@ -0,0 +1,14 @@ +// Gemeinsamer Vertrag mit Person 3: eindeutige id, name und summary (Strings). +// Hier ausschließlich die vom Team freigegebenen Angebote eintragen. +export const packages = []; + +// Ausschließlich für lokale Entwicklung; kein echtes Angebot, kein Preis. +export const demoPackages = [ + { + id: "demo-booking", + name: "1:1-Ernährungsberatung", + summary: + "Persönliche Begleitung rund um deine Ernährung im Alltag und beim Sport.", + testOnly: true, + }, +]; diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js new file mode 100644 index 0000000..7f3af02 --- /dev/null +++ b/src/tests/bookings.test.js @@ -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"); +}); diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js new file mode 100644 index 0000000..c9d584d --- /dev/null +++ b/src/tests/browser/booking.spec.js @@ -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()); + } +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js new file mode 100644 index 0000000..f9f02d8 --- /dev/null +++ b/src/tests/helpers/browser-server.js @@ -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); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..665b58f --- /dev/null +++ b/vite.config.js @@ -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), + ), + }, + }, + }, +});