first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# Architektur- und Designentscheidungen
## Referenzseite
`https://www.freedomfilmmaker.de/filmcamp` war erreichbar. Ausgewertet wurde eine automatisch erzeugte Zusammenfassung des ausgelieferten HTML, keine vollständige visuelle Analyse. Laut dieser Zusammenfassung ist die Reihenfolge: Hero mit Termin, Ort, Preis und CTA, dann Galerie, Tagesablauf, Unterkunftsgalerie, Platzoptionen mit Preisen, Team-Profile, Abschluss-CTA und Footer mit Rechtlichem. Video-Testimonials waren dort nicht erkennbar. Übernommen wurden nur Prinzipien: Fakten schon im Hero, Unterkunft über Bilder statt Text, Team mit Gesicht, früher und wiederholter Buchungseinstieg, eine persistente Buchungsaktion auf Mobilgeräten. Texte, Marken und Medien wurden nicht übernommen.
## Gestaltung
- **Idee:** Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild, Termin, Ort und Preis stehen als Cover-Lines daneben. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
- **Farbe:** Putz-Weiß `#F2ECE3`, Papier `#FAF7F1`, Anthrazit `#23211F`, Stein `#676058`, Linie `#D8CEC0`. Der Akzent ist Pompeji-Rot `#8E3B2B` statt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1.
- **Schrift:** Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über `next/font` selbst ausgeliefert, es gibt keine Verbindung zu Google.
- **Layout:** asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
- **Bewegung:**
- eine orchestrierte Hero-Eröffnung in reinem CSS
- Clip-Reveal nur auf Bildern
- Nav-Indikator
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
- `prefers-reduced-motion` wird überall respektiert, es gibt keine Scroll-Übernahme.
- **Platzhalter:** Fehlende Bilder erscheinen als gestaltete Flächen im Mittelmeer-Ton. In Entwicklung und Staging tragen sie das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
## Stack und Laufzeit
- Next.js 16 App Router mit Server Components. Clientseitig sind nur Header, Buchungsdialog (lazy geladen), mobile Leiste, Galerie, Video-Slots und Statusaktionen.
- Die Ausgabe ist `standalone` und läuft als Docker-Container unter Nicht-Root. Es gibt keine Vercel-Abhängigkeit.
- Der Zustand liegt vollständig in PostgreSQL: Buchungen, Reservierungen, Gutscheine, Rate Limits, E-Mail-Ausgang und Stripe-Events. Die App ist dadurch zustandslos und kann mehrere Replikate haben.
- Die Content-Security-Policy arbeitet mit Nonce und `strict-dynamic` (src/proxy.ts). Stripe Checkout ist gehostet, deshalb braucht die Seite kein Stripe.js.
## ADR-1: Stripe Checkout statt eingebetteter Zahlung
Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, das Speichern der Zahlungsmethode für Folgeraten und dynamische Zahlungsarten. Kartendaten berühren die Anwendung nie, und die CSP bleibt streng. Eine eingebettete Oberfläche hätte keinen Mehrwert gebracht, der den zusätzlichen Integrations- und Sicherheitsaufwand rechtfertigt.
## ADR-2: Preisbindung
**Regel:** Maßgeblich ist der Zeitpunkt des Buchungsstarts. Der dabei serverseitig berechnete Preis gilt, wenn die Zahlung innerhalb der Reservierungszeit abgeschlossen wird (30 Minuten, konfigurierbar, Minimum laut Stripe). Danach verfällt die Reservierung, ein neuer Versuch wird zum dann gültigen Preis berechnet.
**Erzwingung:**
- `expires_at` der Checkout-Session entspricht `priceLockedUntil`, danach ist die Session bei Stripe nicht mehr bezahlbar.
- Der Platz bleibt 10 Minuten länger reserviert als die Session läuft, damit eine kurz vor Ablauf abgeschlossene Zahlung ihren Platz sicher behält.
- Beim Jahreswechsel gilt: Wer am 31.12. um 23:50 Uhr startet, zahlt Early Bird, sofern die Zahlung bis zum angezeigten Ablauf gelingt. Eine Verlängerung gibt es nicht.
- Kommuniziert wird die Regel im Dialog, in der FAQ und im Stripe-Checkout-Text.
## ADR-3: Endliche Ratenpläne
**Gewählt:** Die erste Rate läuft über Stripe Checkout mit `setup_future_usage=off_session`. Die Folgeraten sind einzelne Off-Session-PaymentIntents, die ein idempotenter Kubernetes-CronJob am Fälligkeitstag um 9:00 Uhr Berliner Zeit auslöst.
**Verworfen: Subscription Schedules.** Sie sind laut Stripe-Doku das Standardmuster für Ratenzahlung, rechnen aber in festen Intervallen (z. B. monatlich). Die geforderte Regel „letzte Rate spätestens 30 Tage vor Anreise“ und eine vom Buchungstag abhängige Mitte lassen sich damit nicht sauber abbilden. Außerdem wäre es formal ein Abonnement.
**Gründe für den gewählten Weg:**
- Die Raten sind exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Beträge wie 800 + 700 + 700 € sind direkt abbildbar.
- Endlichkeit ist dreifach gesichert: Es gibt maximal drei Ratenzeilen, jeder Einzug prüft `paid + Rate ≤ Gesamt`, und nach der letzten Rate wird die Zahlungsmethode bei Stripe getrennt.
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
- Verlangt die Bank eine Authentifizierung, geht die Rate auf `REQUIRES_ACTION`, und die Kundin bekommt per E-Mail einen Link und zahlt über eine eigene Checkout-Session.
- Bei Fehlschlag gibt es bis zu drei Versuche im Abstand von drei Tagen, danach „Prüfung nötig“ im Admin.
- Idempotenzschlüssel `offsession:<rate>:<versuch>` verhindern Doppelabbuchungen, auch nach Netzwerkfehlern oder Worker-Abbrüchen.
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
**Aufteilung:** Ist der Betrag ohne Centrest teilbar, sind alle Raten gleich. Sonst werden die Folgeraten auf volle 50 € abgerundet, der Rest fällt in Rate 1. Das reproduziert alle zwölf Vorgabewerte (Tests).
**Termine:** Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
- Jede platzbelegende Transaktion sperrt zuerst die Camp-Zeile (`SELECT … FOR UPDATE`), dann gegebenenfalls die Gutscheinzeile. Durch die feste Reihenfolge entstehen keine Deadlocks. Zwei Personen bekommen nie denselben letzten Platz oder dieselbe letzte Einlösung (Tests mit 8 bzw. 7 parallelen Buchungen).
- Belegt sind CONVERTED- und HELD-Reservierungen (verzögerte Zahlung wie SEPA) sowie ACTIVE-Reservierungen, die noch nicht abgelaufen sind. Ein abgelaufener Checkout gibt Platz und Gutschein automatisch frei.
- Test- und Livebuchungen werden getrennt gezählt (`livemode`).
- Öffentlich sind keine Platzzahl, kein Restplatz-Zähler und keine Liste gültiger Codes sichtbar. Abgelehnte Codes bekommen immer dieselbe neutrale Meldung.
## ADR-5: Webhooks und Zustände
- Die Signatur wird gegen den unveränderten Body geprüft (`request.text()`). Die Event-ID wird in `StripeEvent` gespeichert, wodurch jedes Event genau einmal verarbeitet wird.
- Buchungsstatus und Zahlungsstatus sind getrennt. Die Übergänge sind monoton und laufen unter Buchungssperre, so dass ein spätes „expired“ keine bezahlte Buchung aufhebt.
- Ist nach einer verspäteten Zahlung der Platz inzwischen vergeben, wird nicht überbucht. Die Buchung geht auf „Prüfung nötig“, und Admin und Kundin werden informiert.
- Die Erfolgsseite zeigt nur den Datenbankstand. Bezahlt markieren darf nur der Webhook oder der Worker-Abgleich, der Stripe selbst abfragt.
- E-Mails laufen über einen transaktionalen Postausgang: gleiche Transaktion wie die Statusänderung, Versand danach, Wiederholung durch den Worker.
## ADR-6: Verkaufsfreigabe
`src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen.
- **Livemodus (`sk_live_`):** Verkauf nur mit laufender echter Preisphase, eingeschaltetem Live-Verkauf und allen elf Bestätigungen.
- **Testmodus:** Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
## ADR-7: Adminbereich
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.
+117
View File
@@ -0,0 +1,117 @@
# Betrieb auf RKE2
## 1. Images bauen
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“ beide Images und pusht sie in die Gitea-Registry:
- `main` ergibt die Tags `latest` und `sha-<kurz>`
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
Einmalig einrichten:
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain)
Ein Release entsteht dann so: `git tag v0.1.0 && git push origin v0.1.0`, anschließend den Tag im Overlay setzen.
**Manuell:**
```bash
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-filmcamp:0.1.0 .
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0 .
docker push … # beide Images, immer mit identischem Tag
```
Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber weder Datenbank noch Secrets.
## 2. Einmalig einrichten
1. **Overlay anpassen** (`k8s/overlays/production/`): Image-Namen und Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name.
2. **StorageClasses:**
- PostgreSQL bekommt Blockspeicher mit ReadWriteOnce, auf keinen Fall eine SMB-Freigabe (Begründung in `k8s/base/postgres.yaml`).
- Das Medienvolume braucht ReadWriteMany, sonst `replicas: 1`.
- Das Backup-Volume darf NFS oder SMB sein.
3. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator.
4. **Anwenden:** `kubectl apply -k k8s/overlays/production`
5. **Migration** (siehe Abschnitt 3), danach das erste Admin-Konto:
```bash
kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \
--image=git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0 \
--overrides='{"spec":{"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-config"}},{"secretRef":{"name":"artista-secrets"}}]}]}}'
```
Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet.
## 3. Update ausrollen (kontrollierter Migrationsschritt)
```bash
# 1. Neuen Tag im Overlay setzen, anwenden (App rollt noch NICHT aus, solange Tag gleich bleibt)
kubectl apply -k k8s/overlays/production
# 2. Migration bewusst starten und abwarten
JOB=migrate-$(date +%Y%m%d%H%M)
kubectl -n artista create job $JOB --from=cronjob/artista-migrate
kubectl -n artista wait --for=condition=complete job/$JOB --timeout=5m
# 3. Rollout beobachten
kubectl -n artista rollout status deploy/artista-app
```
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
## 4. Stripe
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
## 5. E-Mail
`MAIL_TRANSPORT=smtp` mit `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER` und `SMTP_PASSWORD`. Den Absender (`MAIL_FROM`) per SPF, DKIM und DMARC für die Absenderdomain freischalten. `ADMIN_NOTIFY_EMAIL` erhält Hinweise zu „Prüfung nötig“. Nicht zustellbare Mails zeigt das Admin-Dashboard. Der Worker versucht jede Mail bis zu achtmal.
## 6. Worker
Der CronJob `artista-worker` läuft alle 10 Minuten und kümmert sich um:
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
- hängende Einzüge
- fällige Raten
- das Trennen der Zahlungsmethode nach der letzten Rate
- den E-Mail-Versand
- das Aufräumen der Rate Limits
Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artista get jobs`).
## 7. Sicherung und Wiederherstellung
- **Sicherung:** Der CronJob `artista-db-backup` erstellt täglich um 3:15 Uhr einen Dump im `pg_dump`-Custom-Format nach `artista-db-backup` und bewahrt ihn 30 Tage auf.
- **Aus dem Cluster sichern (regelmäßig):**
```bash
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-postgres -o name | head -1)
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
```
- **Wiederherstellen:**
1. App stoppen: `kubectl -n artista scale deploy/artista-app --replicas=0` und den Worker-CronJob pausieren: `kubectl -n artista patch cronjob artista-worker -p '{"spec":{"suspend":true}}'`
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
3. Migration laufen lassen (Abschnitt 3), App wieder hochskalieren, Worker fortsetzen.
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
- **Medien:** Das Volume `artista-media` separat sichern, z. B. mit Volume-Snapshots oder der Sicherung des Speichersystems.
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
## 8. Stornierung und Erstattung
Die Software trifft keine Storno-Entscheidung, sie setzt sie nur nachvollziehbar um.
1. **Stornieren** (Admin → Buchung → „Stornieren“, Grund ist Pflicht): Offene Raten werden beendet, Platz und Gutschein freigegeben, die gespeicherte Zahlungsmethode getrennt und offene Checkout-Sessions geschlossen. Danach wird nichts mehr abgebucht.
2. **Erstatten** (je bezahlter Rate, Betrag frei wählbar, höchstens bis zum gezahlten Betrag): Welcher Betrag erstattet wird, bestimmen die freigegebenen Stornobedingungen. Die Erstattung wird bei Stripe ausgelöst, der Zahlungsstatus aktualisiert sich per Webhook (`charge.refunded`). Ein doppeltes Abschicken wird über einen Idempotenzschlüssel abgefangen.
3. **Prüfung nötig** entsteht bei verspäteter Zahlung ohne freien Platz, Doppelzahlung, Rückbuchung oder endgültig fehlgeschlagener Rate. Dann entweder „Buchung bestätigen“ (nur mit freiem Platz, sonst vorher Kapazität erhöhen) oder stornieren und erstatten.
4. **Dokumentation:** Alle Schritte stehen im Protokoll der Buchung (AuditLog) mit handelnder Person.
## 9. Medien
Dateien (jpg, png, webp, avif, mp4, webm) in das Volume `artista-media` kopieren:
```bash
kubectl -n artista cp villa-aussen.jpg <app-pod>:/app/media/villa-aussen.jpg # Volume ist im App-Pod read-only → einen Hilfs-Pod mit Schreibzugriff nutzen
```
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder, etwa von Booking.com, ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen.
+41
View File
@@ -0,0 +1,41 @@
# Noch benötigte Inhalte und Freigaben
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
## Inhalte
| Punkt | Wo eintragen |
|---|---|
| Camp-Termin (Anreise/Abreise) **(Freigabe)** | Admin → Einstellungen |
| Genauer Ort und Region **(Freigabe)** | Admin → Einstellungen |
| Verbindliche Zimmer- und Bettbeschreibung, insbesondere ob ein französisches Doppelbett geteilt wird **(Freigabe)** | Admin → Einstellungen |
| Booking-Link der Unterkunft | `src/content/site.ts` → `accommodation.bookingUrl` |
| Unterkunftsbilder mit geklärter Nutzungsberechtigung | Medienvolume + `site.ts` |
| Namen, Rollen, Kurzbeschreibungen und Porträts der beiden Dozentinnen | `site.ts` → `teachers` |
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
| Drei echte, freigegebene Feedback-Videos mit Namen und Begleittext | Medienvolume + `site.ts` → `videos` |
| Hero-Leitbild oder -Video | `site.ts` → `hero.media` |
| Bestätigte Leistungen (Workshops, Verpflegung, Programm) **(Freigabe)** | `site.ts` → `services`, danach Admin „Leistungen bestätigt“ |
| Verbindliches Programm (bis dahin nur als Beispiel gekennzeichnet) | `site.ts` → `exampleSchedule` |
| Veranstalter- und Kontaktdaten **(Freigabe)** | Admin → Einstellungen |
| Freigabe der Entwurfstexte (Headline, Camp-Text, Zielgruppe) | `site.ts` |
## Recht, Steuer, Zahlung
| Punkt | Hinweis |
|---|---|
| Steuerliche Behandlung und korrekte Preisangabe **(Freigabe)** | Es wird bewusst keine USt-Aussage angezeigt, bis der Hinweis eingetragen und bestätigt ist |
| Buchungs-, Storno- und Zahlungsbedingungen **(Freigabe)** | Als Dokument oder Seite hinterlegen (Link und Version im Admin). Es wurden keine AGB formuliert |
| Reiserechtliche Einordnung (Pauschalreise?) samt gegebenenfalls nötiger Insolvenzabsicherung und Formblätter **(Freigabe)** | Rechtliche Aussagen aus der Kalkulation wurden nicht übernommen |
| Vollständiges Impressum und Datenschutzerklärung | Technische Grundlage siehe `/datenschutz` (nur in Entwicklung sichtbar) |
| Verkaufbare Platzzahl (vorläufig 10) **(Freigabe)** | Admin → Einstellungen |
| Kontingent Stammmodel-Buchungen (vorläufig 4) **(Freigabe)** | Admin → Einstellungen |
| Ratenzahlung: rechtliche Prüfung, Terminregel, Freigabe für den Livemodus | Admin → Einstellungen (zwei Häkchen) |
| Teilnahme Minderjähriger | Die Buchung verlangt derzeit die Erklärung „Ich bin volljährig“. Bitte bestätigen oder die Regel vorgeben |
## Betrieb
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
- Wiederherstellung aus der Sicherung einmal geübt.
+53
View File
@@ -0,0 +1,53 @@
# Tests und Abnahme
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden.
## Automatisierte Tests
`pnpm test` umfasst 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
| Geschäftsrisiko | Datei |
|---|---|
| Alle vier Gesamtpreise, 28 % statt 30 % | `tests/pricing.test.ts` |
| Alle zwölf Ratenbeträge exakt, einschließlich 1.237,50 € | `tests/payment-plan.test.ts` |
| Zeitgrenzen 01.11.2026 und 01.01.2027 in Europe/Berlin, Sommerzeit | `tests/pricing.test.ts`, `booking.integration.test.ts` |
| Ungültige, inaktive, abgelaufene, noch nicht gültige, fremd gebundene und ausgeschöpfte Gutscheine | `booking.integration.test.ts` |
| Gleichzeitige Einlösung eines begrenzten Codes (7 parallel, 2 erlaubt) und Gesamtkontingent | `booking.integration.test.ts` |
| Gleichzeitige Buchungen bei knapper Kapazität (8 parallel, 2 Plätze) | `booking.integration.test.ts` |
| Abgelaufene Reservierungen, verzögerte Zahlung (SEPA), verspätete Zahlung mit und ohne freien Platz, verlorener Webhook | `payments.integration.test.ts` |
| Doppelte und ungeordnete Stripe-Events, Livemodus-Event im Testbetrieb, Signaturprüfung | `payments.integration.test.ts` |
| Zahlungserfolg, Abbruch, Fehler, Teilzahlung, Authentifizierung, Erstattung | `payments.integration.test.ts`, `admin.integration.test.ts` |
| Sperre aller Live-Checkout-Zugänge ohne Freigabe, Vorschauphase | `sales-gate.test.ts`, `booking.integration.test.ts` |
| Keine weitere Abbuchung nach der letzten Rate oder nach Stornierung, Zahlungsmethode getrennt | `payments.integration.test.ts`, `admin.integration.test.ts` |
| Unveränderlicher Preis-Snapshot (DB-Trigger), Idempotenz bei Doppelklick | `booking.integration.test.ts` |
## End-to-End und Sicht
`pnpm test:e2e` umfasst 9 Tests auf den Profilen Smartphone (Pixel 7), Tablet (820 × 1180) und Desktop (1440 × 900):
- axe-Prüfung WCAG 2.1 AA ohne Verstöße
- kein horizontales Scrollen
- keine öffentliche Platzzahl
- Buchungsdialog: Pflichtfelder, Gutschein, Ratenplan 3 × 660 €, Erklärungen, Fokus beim Öffnen und Schließen per Escape
- Admin-Weiterleitung, Export mit gefälschtem Cookie ergibt 401, Statusseite mit `noindex`
Bildschirmfotos aller drei Größen wurden visuell geprüft.
## Manuell über HTTP geprüft
- Admin-Ablauf:
- Öffentliche Registrierung wird mit 400 abgelehnt, ein falsches Passwort mit 401.
- Ohne 2FA leitet `/admin` zur Einrichtung um, der Export antwortet 401.
- Nach der TOTP-Aktivierung sind Dashboard und CSV mit 200 erreichbar.
- CSRF: Anfragen ohne JSON bekommen 415, fremde Origins 403.
- Preismanipulation: Ein mitgeschicktes `totalCents` wird ignoriert.
- Sicherheitsheader (CSP mit Nonce, HSTS, X-Frame-Options) sind gesetzt.
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
## Nicht ausgeführt / Einschränkungen
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Off-Session-Ergebnisse), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste.
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).