# Plan: Testumgebung auf testmodul.qrmaster.net Stand: 2026-08-12 · Ziel: eine vollständige zweite Instanz auf `testmodul.qrmaster.net`, die einen anderen Branch (`test`) fährt, damit riskante Features - z.B. ein Bot, der bei Kundenerfolgen automatisch tweetet - vor dem Merge nach `master` real getestet werden können. **Status: nur Planung. Es wurde noch kein Code geändert.** ## Architektur Zweiter, vollständig eigenständiger Compose-Stack aus demselben Repo, anderer Branch, eigene Datenbank, eigenes Redis. Caddy routet `testmodul.qrmaster.net` auf den Test-Container. ``` www.qrmaster.net ─┐ app.qrmaster.net ─┴─> qrmaster-web (Branch master, Prod-DB) testmodul.qrmaster.net ─> qrmaster-test-web (Branch test, eigene DB) ``` Prod und Test teilen sich **nichts** außer dem Docker-Netzwerk `qrmaster-network` (das ist `external: true`, darüber erreicht Caddy beide Container per Namen). ## Ausgangslage im Repo Im Working Tree liegen uncommittete Änderungen, die nicht aus der Subdomain-Arbeit stammen: - `src/lib/cookieConfig.ts` - `getAuthCookieName()` ist angelegt (liest `AUTH_COOKIE_NAME`, Default `userId`) - `src/lib/session.ts` - importiert `getAuthCookieName`, **benutzt es aber nicht**; Zeile 14 hat weiterhin `export const AUTH_COOKIE_NAME = 'userId'` Damit ist die Funktion aktuell wirkungslos. Die Verkabelung fehlt an 6 Stellen (siehe unten). Vor dem Weiterbauen klären, ob diese Änderungen bewusst so stehen oder committed werden sollen. Ein Branch `test` existiert noch nicht. Vorhanden: `analytics`, `dynamisch`, `feature/mockup-landing-page`, `icons`, `master`. ## Falle 1 - Cookie-Kollision (blockierend) Produktion setzt das Session-Cookie `userId` auf `.qrmaster.net`. Der Browser schickt es damit **auch an `testmodul.qrmaster.net`**. Setzt Test sein eigenes `userId` als Host-Cookie, liegen zwei gleichnamige Cookies vor, und `req.cookies.get('userId')` in [middleware.ts:250](src/middleware.ts:250) liefert undefiniert welches davon. Folge: Login auf Test verhält sich sporadisch wie ausgeloggt - ein Fehlerbild, das schwer zu greifen ist, weil es vom Cookie-Zustand des jeweiligen Browsers abhängt. **Lösung:** Cookie-Name pro Umgebung konfigurierbar. `getAuthCookieName()` fertig verkabeln, Test setzt `AUTH_COOKIE_NAME=userId_test`. Zu ändernde Stellen: | Datei | Was | |---|---| | `src/lib/session.ts:14` | `AUTH_COOKIE_NAME`-Konstante durch `getAuthCookieName()` ersetzen (Zeilen 71, 78 ziehen nach) | | `src/middleware.ts:250` | `req.cookies.get('userId')` → `getAuthCookieName()` | | `src/app/(main)/api/auth/google/route.ts:227` | `cookies.set('userId', …)` | | `src/app/(main)/api/auth/verify-email/route.ts:40` | `cookies.set('userId', …)` | | `src/app/(main)/api/auth/logout/route.ts:12` | Eintrag in `appendExpiredCookies` | | `src/app/(main)/api/user/delete/route.ts:77` | `cookies().delete('userId')` | Wie `COOKIE_DOMAIN` muss `AUTH_COOKIE_NAME` **auch zur Build-Zeit** gesetzt sein - `process.env` wird ins Edge-Middleware-Bundle inlined. Also `ARG` + `ENV` im Dockerfile und als Build-Arg im Compose-Override, analog zu `COOKIE_DOMAIN`. Achtung beim Umstellen der Produktion: ändert sich dort der Cookie-Name, werden **alle bestehenden Sessions ungültig** (alle Nutzer ausgeloggt). Deshalb Prod beim Default `userId` lassen und nur Test abweichen - dann passiert genau nichts. ## Falle 2 - Host-Routing würde nach Prod umleiten Die Middleware kennt nur zwei Hosts. Auf `testmodul.qrmaster.net` greift der Zweig `else if (isAppPath(path))` und schickt `/dashboard` per 301 auf **app.qrmaster.net**, also in die Produktion. **Lösung ohne Codeänderung:** Test zeigt beide Origins auf sich selbst. Dann ist `isHostSplitEnabled()` false, `getAppHostname()` liefert null, und das komplette Host-Routing schaltet sich ab. Test läuft als Ein-Host-Umgebung mit Marketing *und* App unter einer Domain - genau wie die lokale Entwicklung. Das ist der Grund, warum beide Variablen in `.env.test` identisch sein müssen. Wer dort später aus Versehen `NEXT_PUBLIC_APP_URL=https://app.qrmaster.net` einträgt, schickt seine Testklicks in die Produktion. ## Falle 3 - ausgehende Nebenwirkungen Das ist beim Twitter-Bot der eigentliche Punkt. Eine Testumgebung, die auf echte Dienste zeigt, ist keine Testumgebung. | Dienst | Auf Test | |---|---| | Twitter/X | eigener App-Key auf einen Test-Account, **oder** ein `DRY_RUN`-Flag, das den Tweet nur loggt | | Stripe | Test-Keys (`sk_test_…`), eigener Webhook-Endpoint auf testmodul | | Resend / SMTP | Test-Key oder komplett deaktivieren - sonst mailt Test an echte Kunden | | Meta Conversions / PostHog / Umami | leer lassen, sonst verschmutzt Test die Prod-Analytics | | Cron (Retention-Mails) | auf Test abschalten | Empfehlung für den Bot: das `DRY_RUN`-Flag von Anfang an einbauen, nicht erst wenn es einmal schiefging. Ein Bot, der Kundenerfolge tweetet, ist genau die Sorte Feature, die man nicht "kurz mal live" testen will. ## .env.test ```dotenv NODE_ENV=production # Ein-Host-Betrieb: schaltet das Host-Routing ab NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net NEXTAUTH_URL=https://testmodul.qrmaster.net # Host-Cookie, nicht .qrmaster.net - sonst leckt die Test-Session nach Prod COOKIE_DOMAIN= AUTH_COOKIE_NAME=userId_test # Anderes Secret: ein Leak auf Test kann dann keine Prod-Session fälschen NEXTAUTH_SECRET= IP_SALT= # Nicht indexieren NEXT_PUBLIC_INDEXABLE=false # Eigene DB im Test-Stack POSTGRES_USER=postgres POSTGRES_PASSWORD= POSTGRES_DB=qrmaster_test # DATABASE_URL nicht setzen - Compose baut sie aus den drei Werten oben # Test-Keys / leer, siehe Falle 3 STRIPE_SECRET_KEY=sk_test_… RESEND_API_KEY= ``` `NEXTAUTH_URL` darf hier auf testmodul zeigen - der einzige echte Leser ist die Social-Assets-Route, und die soll auf Test ohnehin nicht gegen die verifizierte TikTok-Domain laufen. ## Infra **`docker-compose.test.yml`** als Override, das nur die Abweichungen setzt: - `container_name`: `qrmaster-test-db`, `qrmaster-test-redis`, `qrmaster-test-web` - die Namen sind im Basis-File fest vergeben und würden sonst kollidieren - Host-Ports entfernen - `5435` (db) und `8080` (adminer) sind schon von Prod belegt - `entrypoint: ["node", "server.js"]` für `web`, siehe Migrationen oben - Build-Args für `COOKIE_DOMAIN`, `AUTH_COOKIE_NAME`, `NEXT_PUBLIC_*` ### Netzwerk-Isolation (kritisch) `db` und `redis` hängen im Basis-File am Netzwerk `qrmaster-network` ([docker-compose.yml:22](docker-compose.yml:22)), und das ist `external: true`, also für beide Stacks dasselbe. Compose vergibt jedem Service automatisch einen Netzwerk-Alias mit seinem Servicenamen - zwei Stacks mit einem Service `db` am selben Netzwerk heißt: **`db` löst auf zwei Container auf und Dockers DNS wählt zufällig.** Der Test-Container könnte damit auf der Produktionsdatenbank landen, nicht deterministisch, sondern mal so und mal so. Deshalb bekommt der Test-Stack ein eigenes internes Netzwerk. Nur `web` hängt zusätzlich am geteilten Netz, damit Caddy es erreicht: ```yaml services: db: container_name: qrmaster-test-db ports: !reset [] networks: [test-internal] redis: container_name: qrmaster-test-redis networks: [test-internal] web: container_name: qrmaster-test-web entrypoint: ["node", "server.js"] networks: [test-internal, qrmaster-network] networks: test-internal: driver: bridge ``` `POSTGRES_DB` heißt auf Test `qrmaster_test`. Die Trennung kommt zwar schon vom eigenen Container und Volume, aber der abweichende Name macht bei einer von Hand getippten `psql`-Sitzung sofort sichtbar, auf welcher Instanz man ist - die billigste Versicherung gegen ein `DELETE` in der falschen Datenbank. Dafür muss der Healthcheck mitgezogen werden: das Basis-File hat `pg_isready -d qrmaster` hartkodiert ([Zeile 19](docker-compose.yml:19)). Ohne Override prüft er eine Datenbank, die es nicht gibt, der Container bleibt `unhealthy`, und `web` startet wegen `depends_on: condition: service_healthy` nie. Start: ```bash docker compose -p qrmaster-test --env-file .env.test \ -f docker-compose.yml -f docker-compose.test.yml up -d --build ``` Der Projektname `-p qrmaster-test` gibt automatisch eigene Volumes - die Test-DB kann die Prod-DB also nicht anfassen. **Caddy:** ```caddyfile testmodul.qrmaster.net { reverse_proxy qrmaster-test-web:3000 } ``` **`robots.ts`** an `NEXT_PUBLIC_INDEXABLE` koppeln. Aktuell liefert es hart `Allow` plus www-Sitemap; auf Test soll `Disallow: /` stehen. `NEXT_PUBLIC_INDEXABLE=false` setzt heute nur das Meta-Tag in den beiden Layouts, nicht die robots.txt. ## Branch-Workflow ```bash git checkout -b test master git push -u origin test ``` Deploy auf Test: auf dem Server `git checkout test && git pull`, dann der Compose-Befehl oben. Rebuild ist immer nötig, weil `NEXT_PUBLIC_*` und `AUTH_COOKIE_NAME` zur Build-Zeit inlined werden. Ablauf für ein Feature: Branch von `test` abzweigen → auf Test deployen und prüfen → nach `master` mergen → Prod-Deploy. `test` bleibt dauerhaft bestehen und wird regelmäßig von `master` nachgezogen, damit er nicht wegdriftet. ## Datenbank - entschieden: leeres Schema Eigene PostgreSQL-Instanz im Test-Stack, befüllt mit **Struktur ohne Zeilen**. Keine Kundendaten verlassen die Produktion. ```bash # Struktur aus Prod ziehen (keine Zeilen) docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql # in die Test-DB einspielen docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < schema.sql ``` Danach einen Testaccount anlegen - entweder über das Signup-Formular auf testmodul oder per `INSERT`. **Warum der Dump aus Prod und nicht aus Prisma:** `prisma/migrations` steht auf April 2026. Alles seitdem (Pulse-Spalten, `BARCODE`-Enum-Wert, ...) kam per Hand-SQL. Die Produktions-DB ist damit die einzige Stelle, die die aktuelle Struktur kennt. **Warum leer statt Kopie:** beim Tweet-Bot willst du wissen, welcher Testfall den Tweet ausgelöst hat. Mit Prod-Daten könnte es auch irgendein echter Kunde von vor Monaten gewesen sein. ### Keine Migrationen - der Entrypoint muss überschrieben werden [docker/entrypoint.sh:5](docker/entrypoint.sh:5) führt bei **jedem** Container-Start `npx prisma migrate deploy` aus. Das widerspricht der Policy aus CLAUDE.md (Schema-Änderungen nur per Hand-SQL) und würde auf einer frischen Test-DB die 6 veralteten Migrationen anwenden - also ein Schema vom Stand April 2026 bauen, dem alle späteren SQL-Änderungen fehlen. Die App liefe dann in "column does not exist". Im Test-Override deshalb: ```yaml web: entrypoint: ["node", "server.js"] ``` Prod bleibt unverändert. Der Schema-Dump bringt die Tabelle `_prisma_migrations` ohnehin mit, inklusive der 6 als angewandt markierten Einträge - der Zustand ist also identisch zu Prod. **Separat zu klären (nicht Teil dieses Plans):** ob `prisma migrate deploy` langfristig auch aus dem Prod-Entrypoint verschwinden soll. Heute ist es dort ein No-Op, aber es ist eine scharfe Waffe, die bei einem versehentlich hinzugefügten Migrationsfile auf die Produktion losgeht. ### Spätere Schema-Änderungen Ein SQL-Statement, das auf Test getestet wurde, wird auf Prod **erneut von Hand** ausgeführt - es gibt keinen automatischen Weg von Test nach Prod. Die Dateien in `sql/` sind der Ort dafür. ## Aufwand | | | |---|---| | Cookie-Name verkabeln (6 Stellen) + Dockerfile/Compose | ~1 h | | `docker-compose.test.yml` + `robots.ts` an INDEXABLE koppeln | ~1 h | | `.env.test`, Secrets, Test-Keys besorgen | Timo | | Caddy-Block + erster Deploy | ~30 min | | DB-Variante B zusätzlich | +2-3 h für das Anonymisierungs-Skript | Der Twitter-Bot selbst ist davon unabhängig und noch nicht geschätzt. ## Testcheckliste nach dem ersten Test-Deploy - [ ] `testmodul.qrmaster.net` lädt, gültiges Zertifikat - [ ] `testmodul.qrmaster.net/dashboard` bleibt **auf testmodul** und springt nicht nach app.qrmaster.net - [ ] Login auf Test funktioniert, während man in Prod eingeloggt ist - beide Sessions unabhängig - [ ] Logout auf Test loggt **nicht** aus Prod aus (und umgekehrt) - [ ] Im Browser liegen zwei Cookies: `userId` (Domain `.qrmaster.net`) und `userId_test` (Host `testmodul.qrmaster.net`) - [ ] `curl -sI https://testmodul.qrmaster.net` → kein `X-Robots-Tag` nötig, aber `/robots.txt` liefert `Disallow: /` - [ ] Test-DB enthält **keine** echten Kunden: `SELECT count(*) FROM "User";` muss die Zahl der selbst angelegten Testaccounts sein - [ ] `docker compose -p qrmaster-test exec web env | grep DATABASE_URL` zeigt auf den Test-Container, und `SELECT count(*)` dort weicht von Prod ab - beweist, dass der `db`-Alias nicht auf Prod zeigt - [ ] Container-Logs beim Start enthalten **kein** "Applying Prisma migrations" - [ ] Ein Testlauf des Bots postet nichts auf dem echten Account