Files
QR-master/PLAN_TESTUMGEBUNG_2026-08-12.md
Timo Knuth d623f39c54 Give the staging database its own name
The staging database now runs as qrmaster_test instead of reusing the production name.
Container and volume already kept the two apart, but a hand-typed psql session against
two databases both called `qrmaster` looks identical on either side - the distinct name is
what makes the wrong window obvious before a DELETE lands in it.

The base compose file hardcodes `pg_isready -d qrmaster` in the db healthcheck, so the
overlay has to override the probe as well. Without it the container stays unhealthy and web
never starts, because it waits on service_healthy.

Verified against `docker compose config`: staging resolves to qrmaster_test in POSTGRES_DB,
DATABASE_URL and the healthcheck, while production still resolves to qrmaster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:20:30 +02:00

13 KiB

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.

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 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

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=<eigenes Secret>
IP_SALT=<eigenes Salt>

# Nicht indexieren
NEXT_PUBLIC_INDEXABLE=false

# Eigene DB im Test-Stack
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<eigenes, hex - ein "/" aus base64 zerlegt die DATABASE_URL>
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), 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:

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). 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:

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:

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

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.

# 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 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:

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