pediatric-ai-scribe-v3/docker-compose.e2e.yml
Daniel 5a666f5ca5 test: e2e runs against its own throwaway database, not production's
The e2e stack shared production's Postgres — same server, same database,
same table. Seeded robots sat in `users` beside real clinicians, and
anything a test wrote, or a migration under test changed, landed on real
data. Nothing about "run the tests" should be able to reach an account
belonging to a person.

Now it has a Postgres and a Redis of its own, both on tmpfs: created
empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one
command that recreates the stack, seeds it, runs the browser and leaves
the app up at 127.0.0.1:3553 so it can be clicked around in, with the
report served at :3554.

Two bugs fell out of it immediately, both of which only a database that
did not already exist could have found:

The schema could not be built from nothing. The entrypoint migrated
before the app created its baseline tables, so the first migration
failed on saved_encounters not existing. It never showed because every
database this has ever run against already had the baseline. Then, one
layer down, 1777800000000_generated-images creates a table with a
foreign key to learning_content — which the baseline stopped creating
when Learning Hub was removed. Restoring into a brand-new database could
not have booted. The entrypoint now stands aside when the database is
empty and lets the app do it in the order it already gets right, and the
foreign key is only created where its target is. All 20 migrations
replay from empty, producing the same 23 tables production has.

Configuration lives in the database, so a throwaway one starts at
defaults — 14 settings against production's 49. That is why every model
picker was empty: models.custom did not exist. The tests were right and
the environment was incomplete, so the seed now states what the suite
depends on, with fictional model ids: a test should not pass because of
a setting somebody changed on the live system last week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:49:25 +02:00

141 lines
6.5 KiB
YAML

# E2E test environment — a whole second copy of the app, on its own throwaway
# database, with its own throwaway Redis.
#
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
# docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v postgres-e2e redis-e2e pediatric-scribe-e2e
#
# Normally you want scripts/e2e.sh, which does both around a test run.
#
# It used to share production's Postgres — same server, same database, same
# table. Seeded robots sat in `users` next to real clinicians, and anything a
# test wrote, or a migration under test changed, landed on real data. Nothing
# about "run the tests" should be able to reach an account belonging to a
# person. Now the stack has a database of its own, held in a tmpfs: it exists
# in RAM, it is created empty on every `up`, and it is gone on `down`. The
# schema is rebuilt each time by the container's own migrations, which also
# means every run proves the migrations still work from nothing.
services:
# ── Throwaway Postgres ────────────────────────────────────────────────
# Same pinned image as production, so an e2e pass says something about what
# production will do. PGDATA points at a subdirectory because initdb wants a
# 0700 directory of its own and a tmpfs mountpoint is not one.
postgres-e2e:
image: pgvector/pgvector:pg16@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc
container_name: pedscribe-db-e2e
environment:
POSTGRES_DB: pedscribe_e2e
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: e2e-throwaway
PGDATA: /var/lib/postgresql/data/pgdata
tmpfs:
# In RAM, so there is no volume to forget to clean up and nothing to
# survive a reboot. 1G is far more than a seeded test run uses.
- /var/lib/postgresql/data:size=1g
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedscribe -d pedscribe_e2e"]
interval: 3s
timeout: 5s
retries: 20
restart: "no"
# ── Throwaway Redis ───────────────────────────────────────────────────
# Sessions and rate-limit counters. Persistence off in both directions: no
# RDB snapshots, no AOF, and /data on tmpfs, so a run cannot inherit state
# from the one before it.
redis-e2e:
image: redis:8-alpine@sha256:d146f83b1e0f02fc27c26a50cee39338c736674c5959db84363e6ae3cd9e02d2
container_name: ped-ai-redis-e2e
command: ["redis-server", "--save", "", "--appendonly", "no"]
tmpfs:
- /data:size=64m
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 5s
retries: 20
restart: "no"
# ── The app under test ────────────────────────────────────────────────
pediatric-scribe-e2e:
build:
context: .
args:
GIT_REVISION: ${GIT_REVISION:-unknown}
image: ped-ai-e2e:latest
ports:
- "127.0.0.1:3553:3000"
networks:
# Its own project network, plus the converter so PDF export is exercised
# here too. Without this the e2e stack could only reach Postgres and
# Redis, and a PDF download failed in a way production would not.
- default
- danvics_convert
env_file:
- .env
environment:
# These four are the isolation. The entrypoint applies OpenBao secrets
# only for keys docker has not already set, so anything named here wins
# over the vault — which is exactly what that rule was written for.
DATABASE_URL: postgresql://pedscribe:e2e-throwaway@postgres-e2e:5432/pedscribe_e2e
REDIS_URL: redis://redis-e2e:6379
# Never mail a real person from a test run.
SMTP_HOST: ""
# Disable Turnstile entirely — both server-side verification AND the
# client-side widget. Without clearing the SITE_KEY the frontend tries
# to initialise the Turnstile iframe against the prod domain and
# throws error 110200, which Playwright's pageerror guard correctly
# flags as an uncaught exception.
TURNSTILE_SECRET_KEY: ""
TURNSTILE_SITE_KEY: ""
# A key of its own. Rows here are throwaway, and binding them to the
# production key would be the one piece of production that leaked in.
DATA_ENCRYPTION_KEY: "e2e0000000000000000000000000000000000000000000000000000000000e2e"
# Raise the login rate-limit so Playwright multi-worker runs don't
# trip the production 10/15min cap. Only affects this e2e container.
LOGIN_RATE_LIMIT_MAX: "500"
# Also raise the global /api/ limit so multi-spec Playwright runs
# that make hundreds of API calls don't burn through the 200/min cap.
API_RATE_LIMIT_MAX: "5000"
# Allow fetches from the two origins Playwright serves tests from —
# the in-network hostname and the host-port loopback. Without this
# the CORS middleware (scoped to /api) rejects any non-GET request
# because .env's APP_URL points at the production domain.
CORS_ORIGINS: "http://pediatric-ai-scribe-e2e:3000,http://host.docker.internal:3553,http://localhost:3553,http://127.0.0.1:3553"
volumes:
- scribe-logs-e2e:/app/data/logs
depends_on:
postgres-e2e:
condition: service_healthy
redis-e2e:
condition: service_healthy
container_name: pediatric-ai-scribe-e2e
# Not unless-stopped: this is a test rig, not a service. It should not come
# back on its own after a reboot, and it should not outlive a `down`.
restart: "no"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
# ── The last run's report ─────────────────────────────────────────────
# Playwright writes a self-contained HTML report; this serves it so there is
# a link to open rather than a directory to find. Traces and screenshots of
# failures are in there, which is the part worth looking at on a phone.
e2e-report:
image: nginx:alpine
container_name: pediatric-ai-scribe-e2e-report
ports:
- "127.0.0.1:3554:80"
volumes:
- ./e2e/playwright-report:/usr/share/nginx/html:ro
restart: "no"
volumes:
scribe-logs-e2e:
networks:
danvics_convert:
external: true