From 6389be4f0ce5dea2e445228f857f8b1cabe63e25 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 26 Dec 2025 20:54:20 +0100 Subject: [PATCH] env vars --- .env.development | 87 ++++++++++---- .env.development.example | 83 ++++++++++++- .env.production | 93 +++++++++------ .env.production.example | 112 ++++++++++-------- .env.test.example | 28 ++++- DOCKER_SETUP_ANALYSIS.md | 6 +- README.docker.md | 91 ++++++++++++-- adapters/env.d.ts | 44 +++++++ adapters/tsconfig.json | 2 +- apps/api/src/app.module.ts | 12 +- apps/api/src/env.d.ts | 34 ++++++ apps/api/src/env.ts | 62 ++++++++++ apps/api/src/main.ts | 7 +- apps/api/tsconfig.json | 3 +- apps/website/env.d.ts | 25 ++++ apps/website/lib/config/env.ts | 96 +++++++++++++++ apps/website/lib/rate-limit.ts | 7 +- .../leagues/LeagueMembershipService.ts | 17 ++- apps/website/tsconfig.json | 1 + docker-compose.dev.yml | 2 +- docker-compose.test.yml | 10 +- docs/TESTS.md | 77 ++++++------ package.json | 4 +- playwright.website.config.ts | 2 +- tests/env.d.ts | 34 ++++++ tsconfig.tests.json | 1 + 26 files changed, 745 insertions(+), 195 deletions(-) create mode 100644 adapters/env.d.ts create mode 100644 apps/api/src/env.d.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/website/lib/config/env.ts create mode 100644 tests/env.d.ts diff --git a/.env.development b/.env.development index a14ce51f9..590539346 100644 --- a/.env.development +++ b/.env.development @@ -1,45 +1,84 @@ # ========================================== # GridPilot Development Environment # ========================================== +# Used by `docker-compose.dev.yml` via `env_file: .env.development`. -# Node Environment +# ------------------------------------------ +# Runtime +# ------------------------------------------ NODE_ENV=development +NEXT_TELEMETRY_DISABLED=1 + +# ------------------------------------------ +# API (NestJS) +# ------------------------------------------ +# API persistence is inferred from DATABASE_URL by default. +# GRIDPILOT_API_PERSISTENCE=postgres -# ========================================== -# Database (PostgreSQL) -# ========================================== DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev + +# Postgres container vars (used by `docker-compose.dev.yml` -> `db`) POSTGRES_DB=gridpilot_dev POSTGRES_USER=gridpilot_user POSTGRES_PASSWORD=gridpilot_dev_pass -# ========================================== -# API Configuration -# ========================================== -API_PORT=3000 -API_HOST=0.0.0.0 - -# ========================================== -# Website Configuration -# ========================================== +# ------------------------------------------ +# Website (Next.js) - public (exposed to browser) +# ------------------------------------------ NEXT_PUBLIC_GRIDPILOT_MODE=alpha NEXT_PUBLIC_SITE_URL=http://localhost:3000 -NEXT_PUBLIC_API_URL=http://localhost:3001 -NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 -NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code -NEXT_TELEMETRY_DISABLED=1 -# ========================================== -# Vercel KV (Optional in Development) -# ========================================== +# Browser → API base URL (host port 3001 -> container port 3000) +NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 + +NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code +NEXT_PUBLIC_X_URL=https://x.com/your-handle + +# Optional site/legal metadata (defaults used when unset) +# NEXT_PUBLIC_SITE_NAME=GridPilot +# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com +# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com +# NEXT_PUBLIC_LEGAL_COMPANY_NAME= +# NEXT_PUBLIC_LEGAL_VAT_ID= +# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY= +# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS= + +# ------------------------------------------ +# Vercel KV (optional in dev) +# ------------------------------------------ +# If unset, the Website uses an in-memory fallback with a warning. # KV_REST_API_URL= # KV_REST_API_TOKEN= -# ========================================== -# Automation Mode -# ========================================== -AUTOMATION_MODE=dev +# ------------------------------------------ +# Automation / Companion (advanced) +# ------------------------------------------ +# Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated. +# AUTOMATION_MODE=dev CHROME_DEBUG_PORT=9222 +# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/ + +# Native automation tuning (optional) +# IRACING_WINDOW_TITLE=iRacing +# TEMPLATE_PATH=./resources/templates/iracing +# OCR_CONFIDENCE=0.9 +# NUTJS_MOUSE_SPEED=1000 +# NUTJS_KEYBOARD_DELAY=50 +# AUTOMATION_MAX_RETRIES=3 +# AUTOMATION_BASE_DELAY_MS=500 +# AUTOMATION_MAX_DELAY_MS=5000 +# AUTOMATION_BACKOFF_MULTIPLIER=2 +# AUTOMATION_PAGE_LOAD_WAIT_MS=5000 +# AUTOMATION_INTER_ACTION_DELAY_MS=200 +# AUTOMATION_POST_CLICK_DELAY_MS=300 +# AUTOMATION_PRE_STEP_DELAY_MS=100 + AUTOMATION_TIMEOUT=30000 RETRY_ATTEMPTS=3 SCREENSHOT_ON_ERROR=true + +# Logging (automation/adapters) +# LOG_LEVEL=debug +# LOG_FILE_PATH=./logs/gridpilot +# LOG_MAX_FILES=7 +# LOG_MAX_SIZE=10m diff --git a/.env.development.example b/.env.development.example index 2884135c7..7e6ad3e51 100644 --- a/.env.development.example +++ b/.env.development.example @@ -1,18 +1,89 @@ -# Development Environment Configuration -# Copy this file to .env.development and adjust values as needed +# ========================================== +# GridPilot Development Environment Example +# ========================================== +# Copy to `.env.development` and adjust values as needed. +# +# This file is consumed by `docker-compose.dev.yml` (API, Website, Postgres). -# Automation mode: 'dev' | 'production' | 'mock' -AUTOMATION_MODE=dev +# ------------------------------------------ +# Runtime +# ------------------------------------------ +NODE_ENV=development +NEXT_TELEMETRY_DISABLED=1 -# Chrome DevTools settings (for dev mode) +# ------------------------------------------ +# API (NestJS) +# ------------------------------------------ +# API persistence is inferred from DATABASE_URL by default. +# GRIDPILOT_API_PERSISTENCE=postgres + +DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev + +# Postgres container vars (used by docker `db` service) +POSTGRES_DB=gridpilot_dev +POSTGRES_USER=gridpilot_user +POSTGRES_PASSWORD=gridpilot_dev_pass + +# ------------------------------------------ +# Website (Next.js) - public (exposed to browser) +# ------------------------------------------ +NEXT_PUBLIC_GRIDPILOT_MODE=alpha +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# Browser → API base URL (host port 3001 -> container port 3000) +NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 + +# Optional links / metadata +NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code +NEXT_PUBLIC_X_URL=https://x.com/your-handle +# NEXT_PUBLIC_SITE_NAME=GridPilot +# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com +# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com +# NEXT_PUBLIC_LEGAL_COMPANY_NAME= +# NEXT_PUBLIC_LEGAL_VAT_ID= +# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY= +# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS= + +# ------------------------------------------ +# Vercel KV (optional in dev) +# ------------------------------------------ +# If unset, the Website uses an in-memory fallback with a warning. +# KV_REST_API_URL= +# KV_REST_API_TOKEN= + +# ------------------------------------------ +# Automation / Companion (advanced) +# ------------------------------------------ +# Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated. +# AUTOMATION_MODE=dev CHROME_DEBUG_PORT=9222 # CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/ -# Shared automation settings +# Native automation tuning (optional) +# IRACING_WINDOW_TITLE=iRacing +# TEMPLATE_PATH=./resources/templates/iracing +# OCR_CONFIDENCE=0.9 +# NUTJS_MOUSE_SPEED=1000 +# NUTJS_KEYBOARD_DELAY=50 +# AUTOMATION_MAX_RETRIES=3 +# AUTOMATION_BASE_DELAY_MS=500 +# AUTOMATION_MAX_DELAY_MS=5000 +# AUTOMATION_BACKOFF_MULTIPLIER=2 +# AUTOMATION_PAGE_LOAD_WAIT_MS=5000 +# AUTOMATION_INTER_ACTION_DELAY_MS=200 +# AUTOMATION_POST_CLICK_DELAY_MS=300 +# AUTOMATION_PRE_STEP_DELAY_MS=100 + AUTOMATION_TIMEOUT=30000 RETRY_ATTEMPTS=3 SCREENSHOT_ON_ERROR=true +# Logging (automation/adapters) +# LOG_LEVEL=debug +# LOG_FILE_PATH=./logs/gridpilot +# LOG_MAX_FILES=7 +# LOG_MAX_SIZE=10m + # Start Chrome with debugging enabled: # /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug # Or use: npm run chrome:debug \ No newline at end of file diff --git a/.env.production b/.env.production index a8ca0225f..bb547de73 100644 --- a/.env.production +++ b/.env.production @@ -1,55 +1,78 @@ # ========================================== # GridPilot Production Environment # ========================================== +# Used by `docker-compose.prod.yml` via `env_file: .env.production`. +# +# Security: +# - Do NOT commit real secrets (use a secret manager / CI vars). +# - Replace all `CHANGE_ME_...` values before deploying. -# Node Environment +# ------------------------------------------ +# Runtime +# ------------------------------------------ NODE_ENV=production +NEXT_TELEMETRY_DISABLED=1 -# ========================================== -# Database (PostgreSQL) -# ========================================== -# IMPORTANT: Change these credentials in production! +# ------------------------------------------ +# API (NestJS) +# ------------------------------------------ +# Controls whether the API uses Postgres or runs in-memory. +# Values: `postgres` | `inmemory` +GRIDPILOT_API_PERSISTENCE=postgres + +# Optional: bootstrap demo data (default: true) +# GRIDPILOT_API_BOOTSTRAP=true + +# Database connection for the API (preferred: single URL) DATABASE_URL=postgres://gridpilot_user:CHANGE_ME_IN_PRODUCTION@db:5432/gridpilot_prod + +# Postgres container vars (used by `docker-compose.prod.yml` -> `db` service) POSTGRES_DB=gridpilot_prod POSTGRES_USER=gridpilot_user POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION -# ========================================== -# Redis Cache -# ========================================== -# IMPORTANT: Change password in production! -REDIS_URL=redis://:CHANGE_ME_IN_PRODUCTION@redis:6379 +# ------------------------------------------ +# Redis container vars (used by `docker-compose.prod.yml` -> `redis` service) +# ------------------------------------------ REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION -REDIS_HOST=redis -REDIS_PORT=6379 -# ========================================== -# API Configuration -# ========================================== -API_PORT=3000 -API_HOST=0.0.0.0 - -# ========================================== -# Website Configuration -# ========================================== +# ------------------------------------------ +# Website (Next.js) - public (exposed to browser) +# ------------------------------------------ NEXT_PUBLIC_GRIDPILOT_MODE=alpha -NEXT_PUBLIC_SITE_URL=http://localhost:80 -NEXT_PUBLIC_API_URL=http://localhost:80/api +NEXT_PUBLIC_SITE_URL=https://your-domain.com + +# Browser → API base URL. +# If nginx proxies `/api` to the API service, this is typically: +# https://your-domain.com/api +NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api + +# Optional links / metadata (defaults used when unset) NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code -NEXT_TELEMETRY_DISABLED=1 +NEXT_PUBLIC_X_URL=https://x.com/your-handle +# NEXT_PUBLIC_SITE_NAME=GridPilot +# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com +# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com +# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH +# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789 +# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE +# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City -# ========================================== -# Vercel KV (REQUIRED in Production) -# ========================================== -# For local testing, these can be left as placeholders -# In production, get these from: https://vercel.com/dashboard -> Storage -> KV -KV_REST_API_URL=https://placeholder-kv.vercel-storage.com -KV_REST_API_TOKEN=placeholder_kv_token +# ------------------------------------------ +# Website (Next.js) - server (NOT exposed to browser) +# ------------------------------------------ +# SSR/server-to-server base URL inside Docker network +API_BASE_URL=http://api:3000 -# ========================================== -# Automation Mode -# ========================================== -AUTOMATION_MODE=production +# Vercel KV (required for email signup storage/rate limit in production) +KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com +KV_REST_API_TOKEN=CHANGE_ME_IN_PRODUCTION + +# ------------------------------------------ +# Automation / Companion (advanced) +# ------------------------------------------ +# NOTE: `AUTOMATION_MODE` is deprecated (see `getAutomationMode()` in adapters). +# AUTOMATION_MODE=production AUTOMATION_TIMEOUT=30000 RETRY_ATTEMPTS=3 SCREENSHOT_ON_ERROR=false \ No newline at end of file diff --git a/.env.production.example b/.env.production.example index 3f3ba306a..c6df60942 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,62 +1,70 @@ # ========================================== # GridPilot Production Environment Example # ========================================== -# Copy this file to .env.production and update with real credentials +# Copy to `.env.production` and replace placeholders with real values. +# +# Do NOT commit real secrets. -# Node Environment +# ------------------------------------------ +# Runtime +# ------------------------------------------ NODE_ENV=production - -# ========================================== -# Database (PostgreSQL) -# ========================================== -# Update these with your production database credentials -DATABASE_URL=postgres://gridpilot_user:YOUR_SECURE_PASSWORD@db:5432/gridpilot_prod -POSTGRES_DB=gridpilot_prod -POSTGRES_USER=gridpilot_user -POSTGRES_PASSWORD=YOUR_SECURE_PASSWORD - -# ========================================== -# Redis Cache -# ========================================== -# Update with your production Redis password -REDIS_URL=redis://:YOUR_REDIS_PASSWORD@redis:6379 -REDIS_PASSWORD=YOUR_REDIS_PASSWORD -REDIS_HOST=redis -REDIS_PORT=6379 - -# ========================================== -# API Configuration -# ========================================== -API_PORT=3000 -API_HOST=0.0.0.0 - -# ========================================== -# Website Configuration -# ========================================== -# Update with your actual domain -NEXT_PUBLIC_GRIDPILOT_MODE=alpha -NEXT_PUBLIC_SITE_URL=https://your-domain.com -NEXT_PUBLIC_API_URL=https://api.your-domain.com -NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code NEXT_TELEMETRY_DISABLED=1 -# ========================================== -# Vercel KV (REQUIRED in Production) -# ========================================== -# Get these from: https://vercel.com/dashboard -> Storage -> KV -KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com -KV_REST_API_TOKEN=your_kv_rest_api_token_here +# ------------------------------------------ +# API (NestJS) +# ------------------------------------------ +GRIDPILOT_API_PERSISTENCE=postgres +# GRIDPILOT_API_BOOTSTRAP=true -# ========================================== -# Automation Mode -# ========================================== -AUTOMATION_MODE=production +# Prefer a single connection URL (Docker: host `db`) +DATABASE_URL=postgres://gridpilot_user:CHANGE_ME@db:5432/gridpilot_prod + +# Postgres container vars (used by `docker-compose.prod.yml` -> `db`) +POSTGRES_DB=gridpilot_prod +POSTGRES_USER=gridpilot_user +POSTGRES_PASSWORD=CHANGE_ME + +# Redis container vars (used by `docker-compose.prod.yml` -> `redis`) +REDIS_PASSWORD=CHANGE_ME + +# ------------------------------------------ +# Website (Next.js) - public (exposed to browser) +# ------------------------------------------ +NEXT_PUBLIC_GRIDPILOT_MODE=alpha +NEXT_PUBLIC_SITE_URL=https://your-domain.com + +# Browser → API base URL. +# If nginx proxies `/api` to the API service, this is typically: +# https://your-domain.com/api +NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api + +NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code +NEXT_PUBLIC_X_URL=https://x.com/your-handle + +# Optional site/legal metadata (defaults used when unset) +# NEXT_PUBLIC_SITE_NAME=GridPilot +# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com +# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com +# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH +# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789 +# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE +# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City + +# ------------------------------------------ +# Website (Next.js) - server (NOT exposed to browser) +# ------------------------------------------ +API_BASE_URL=http://api:3000 + +# Vercel KV (required in production) +KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com +KV_REST_API_TOKEN=CHANGE_ME + +# ------------------------------------------ +# Automation / Companion (advanced) +# ------------------------------------------ +# NOTE: `AUTOMATION_MODE` is deprecated. +# AUTOMATION_MODE=production AUTOMATION_TIMEOUT=30000 RETRY_ATTEMPTS=3 -SCREENSHOT_ON_ERROR=false - -# ========================================== -# Security & Performance -# ========================================== -# Add any additional production-specific variables here -# Example: API keys, webhook URLs, etc. \ No newline at end of file +SCREENSHOT_ON_ERROR=false \ No newline at end of file diff --git a/.env.test.example b/.env.test.example index f155d901f..43bb0d1bb 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,10 +1,28 @@ -# Test Environment Configuration -# Copy this file to .env.test and adjust values as needed +# Test Environment Configuration (automation stack) +# Copy this file to `.env.test` and adjust values as needed. +# +# Scope: +# - This file is for the hosted-session automation stack (companion/automation). +# - It is NOT used by the Website ↔ API Docker smoke stack (that stack is driven by env vars in +# [`docker-compose.test.yml`](docker-compose.test.yml:1) and scripts in [`package.json`](package.json:92)). +# +# Note on legacy config: +# - `AUTOMATION_MODE` is deprecated in favor of `NODE_ENV` mapping in [`getAutomationMode()`](adapters/automation/config/AutomationConfig.ts:104). +# - Keep `AUTOMATION_MODE` only if you need to force legacy behavior. -# Use mock adapter for testing (no real browser automation) +# Prefer this: +NODE_ENV=test + +# Optional legacy override (deprecated): `dev` | `mock` | `production` AUTOMATION_MODE=mock -# Test timeouts (can be shorter for faster tests) +# Shared automation settings AUTOMATION_TIMEOUT=5000 RETRY_ATTEMPTS=1 -SCREENSHOT_ON_ERROR=false \ No newline at end of file +SCREENSHOT_ON_ERROR=false + +# Logging (automation/adapters) +# LOG_LEVEL=warn +# LOG_FILE_PATH=./logs/gridpilot +# LOG_MAX_FILES=7 +# LOG_MAX_SIZE=10m \ No newline at end of file diff --git a/DOCKER_SETUP_ANALYSIS.md b/DOCKER_SETUP_ANALYSIS.md index c973468b0..4608b32e8 100644 --- a/DOCKER_SETUP_ANALYSIS.md +++ b/DOCKER_SETUP_ANALYSIS.md @@ -115,8 +115,8 @@ Before deploying to production: - Update `DATABASE_URL` with production database 4. **Redis Password**: - - Update `REDIS_PASSWORD` with strong password - - Update `REDIS_URL` accordingly + - Update `REDIS_PASSWORD` with a strong password + - No `REDIS_URL` is required (the Redis container is configured via `REDIS_PASSWORD` in `docker-compose.prod.yml`) 5. **Vercel KV** (if using): - Get credentials from Vercel dashboard @@ -124,7 +124,7 @@ Before deploying to production: 6. **Domain Configuration**: - Update `NEXT_PUBLIC_SITE_URL` with your domain - - Update `NEXT_PUBLIC_API_URL` with your API domain + - Update `NEXT_PUBLIC_API_BASE_URL` with your public API base (often `https://your-domain.com/api` when nginx proxies `/api`) 7. **Build & Deploy**: ```bash diff --git a/README.docker.md b/README.docker.md index ce267e681..8ce7d7d26 100644 --- a/README.docker.md +++ b/README.docker.md @@ -13,13 +13,13 @@ npm run docker:dev:build This will: - Start PostgreSQL database on port 5432 -- Start API on port 3000 (with debugger on 9229) -- Start Website on port 3001 +- Start API on port 3001 (container port 3000, debugger 9229) +- Start Website on port 3000 - Enable hot-reloading for both apps Access: -- Website: http://localhost:3001 -- API: http://localhost:3000 +- Website: http://localhost:3000 +- API: http://localhost:3001 - Database: localhost:5432 ### Production @@ -54,16 +54,93 @@ Access: - `npm run docker:prod:logs` - View logs - `npm run docker:prod:clean` - Stop and remove volumes +### Testing (Docker) +The goal of Docker-backed tests is to catch *wiring* issues between Website ↔ API (wrong hostnames/ports/env vars, missing CORS for credentialed requests, etc.) in a deterministic environment. + +- `npm run test:docker:website` - Start a dedicated test stack (ports `3100/3101`) and run Playwright smoke tests against it. + - Uses [`docker-compose.test.yml`](docker-compose.test.yml:1). + - Runs the Website in Docker and talks to an **API mock** container. + - Validates that pages render and that core API fetches succeed (hostname + CORS + routing). + +Supporting scripts: +- `npm run docker:test:deps` - Install monorepo deps inside a reusable Docker volume (one-shot). +- `npm run docker:test:up` - Bring up the test stack. +- `npm run docker:test:wait` - Wait for `http://localhost:3100` and `http://localhost:3101/health` to be ready. +- `npm run docker:test:down` - Tear the test stack down (including volumes). + ## Environment Variables +### “Mock vs Real” (Website & API) + +There is **no** `AUTOMATION_MODE` equivalent for the Website/API runtime. + +- **Website “mock vs real”** is controlled purely by *which API base URL you point it at* via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6): + - Browser calls use `NEXT_PUBLIC_API_BASE_URL` + - Server/Next.js calls use `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL` + +- **API “mock vs real”** is controlled by API runtime env: + - Persistence: `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` in [`AppModule`](apps/api/src/app.module.ts:25) + - Optional bootstrapping: `GRIDPILOT_API_BOOTSTRAP=0|1` in [`AppModule`](apps/api/src/app.module.ts:35) + +Practical presets: +- **Website + real API (Docker dev)**: `npm run docker:dev:build` (Website `3000`, API `3001`, Postgres required). + - Website browser → API: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001` + - Website container → API container: `API_BASE_URL=http://api:3000` +- **Website + mock API (Docker smoke)**: `npm run test:docker:website` (Website `3100`, API mock `3101`). + - API mock is defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24) + - Website browser → API mock: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` + - Website container → API mock container: `API_BASE_URL=http://api:3000` + +### Website ↔ API Connection + +The website talks to the API via `fetch()` in [`BaseApiClient`](apps/website/lib/api/base/BaseApiClient.ts:11), and it always includes cookies (`credentials: 'include'`). That means: + +- The **browser** must be pointed at a host-accessible API URL via `NEXT_PUBLIC_API_BASE_URL` +- The **server** (Next.js / Node) must be pointed at a container-network API URL via `API_BASE_URL` (when running in Docker) + +The single source of truth for “what base URL should I use?” is [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6): +- Browser: reads `NEXT_PUBLIC_API_BASE_URL` +- Server: reads `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL` +- In Docker/CI/test: throws if missing (no silent localhost fallback) + +#### Dev Docker defaults (docker-compose.dev.yml) +- Website: `http://localhost:3000` +- API: `http://localhost:3001` (maps to container `api:3000`) +- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001` (browser → host port) +- `API_BASE_URL=http://api:3000` (website container → api container) + +#### Test Docker defaults (docker-compose.test.yml) +This stack is intended for deterministic smoke tests and uses different host ports to avoid colliding with `docker:dev`: + +- Website: `http://localhost:3100` +- API mock: `http://localhost:3101` (maps to container `api:3000`) +- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` (browser → host port) +- `API_BASE_URL=http://api:3000` (website container → api container) + +Important: the test stack’s API is a mock server defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24). It exists to validate Website ↔ API wiring, not domain correctness. + +#### Troubleshooting +- If `docker:dev` is running, use `npm run docker:dev:down` before `npm run test:docker:website` to avoid port conflicts. +- If Docker volumes get stuck, run `npm run docker:test:down` (it uses `--remove-orphans` + `rm -f`). + +### API “Real vs In-Memory” Mode + +The API can now be run either: +- **postgres**: loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) (requires Postgres) +- **inmemory**: does not load `DatabaseModule` (no Postgres required) + +Control it with: +- `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` (defaults to `postgres` if `DATABASE_URL` is set, otherwise `inmemory`) +- Optional: `GRIDPILOT_API_BOOTSTRAP=0` to skip [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:1) + ### Development (.env.development) Copy and customize as needed. Default values work out of the box. ### Production (.env.production) **IMPORTANT**: Update these before deploying: -- Database credentials (POSTGRES_PASSWORD, DATABASE_URL) -- API URLs (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL) -- Vercel KV credentials (required for production) +- Database credentials (`POSTGRES_PASSWORD`, `DATABASE_URL`) +- Website/API URLs (`NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_SITE_URL`) +- Vercel KV credentials (`KV_REST_API_URL`, `KV_REST_API_TOKEN`) (required for production email signups/rate limit) ## Architecture diff --git a/adapters/env.d.ts b/adapters/env.d.ts new file mode 100644 index 000000000..5b07f808c --- /dev/null +++ b/adapters/env.d.ts @@ -0,0 +1,44 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV?: 'development' | 'production' | 'test'; + + // Automation (legacy + current) + AUTOMATION_MODE?: 'dev' | 'production' | 'mock'; + AUTOMATION_TIMEOUT?: string; + RETRY_ATTEMPTS?: string; + SCREENSHOT_ON_ERROR?: string; + + // Chrome / DevTools + CHROME_DEBUG_PORT?: string; + CHROME_WS_ENDPOINT?: string; + + // Native automation tuning + NUTJS_MOUSE_SPEED?: string; + NUTJS_KEYBOARD_DELAY?: string; + IRACING_WINDOW_TITLE?: string; + TEMPLATE_PATH?: string; + OCR_CONFIDENCE?: string; + + // Retry tuning + AUTOMATION_MAX_RETRIES?: string; + AUTOMATION_BASE_DELAY_MS?: string; + AUTOMATION_MAX_DELAY_MS?: string; + AUTOMATION_BACKOFF_MULTIPLIER?: string; + + // Timing tuning + AUTOMATION_PAGE_LOAD_WAIT_MS?: string; + AUTOMATION_INTER_ACTION_DELAY_MS?: string; + AUTOMATION_POST_CLICK_DELAY_MS?: string; + AUTOMATION_PRE_STEP_DELAY_MS?: string; + + // Logging + LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + LOG_FILE_PATH?: string; + LOG_MAX_FILES?: string; + LOG_MAX_SIZE?: string; + } + } +} + +export {}; \ No newline at end of file diff --git a/adapters/tsconfig.json b/adapters/tsconfig.json index da89d9462..d629e292f 100644 --- a/adapters/tsconfig.json +++ b/adapters/tsconfig.json @@ -7,6 +7,6 @@ "sourceMap": true, "types": ["vitest/globals"] }, - "include": ["**/*.ts", "../core/**/*.ts"], + "include": ["**/*.ts", "**/*.d.ts", "../core/**/*.ts", "../core/**/*.d.ts"], "exclude": ["node_modules", "dist"] } \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ce1a982fc..8c9dd60dc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,12 +17,20 @@ import { RaceModule } from './domain/race/RaceModule'; import { SponsorModule } from './domain/sponsor/SponsorModule'; import { TeamModule } from './domain/team/TeamModule'; +import { getApiPersistence, getEnableBootstrap } from './env'; + +const API_PERSISTENCE = getApiPersistence(); +const USE_DATABASE = API_PERSISTENCE === 'postgres'; + +// Keep bootstrap on by default; tests can disable explicitly. +const ENABLE_BOOTSTRAP = getEnableBootstrap(); + @Module({ imports: [ HelloModule, - DatabaseModule, + ...(USE_DATABASE ? [DatabaseModule] : []), LoggingModule, - BootstrapModule, + ...(ENABLE_BOOTSTRAP ? [BootstrapModule] : []), AnalyticsModule, AuthModule, DashboardModule, diff --git a/apps/api/src/env.d.ts b/apps/api/src/env.d.ts new file mode 100644 index 000000000..5fc453ec0 --- /dev/null +++ b/apps/api/src/env.d.ts @@ -0,0 +1,34 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV?: 'development' | 'production' | 'test'; + + // API runtime toggles + GRIDPILOT_API_PERSISTENCE?: 'postgres' | 'inmemory'; + GRIDPILOT_API_BOOTSTRAP?: string; + GENERATE_OPENAPI?: string; + + // Database (TypeORM) + DATABASE_URL?: string; + DATABASE_HOST?: string; + DATABASE_PORT?: string; + DATABASE_USER?: string; + DATABASE_PASSWORD?: string; + DATABASE_NAME?: string; + + // Policy / operational mode + GRIDPILOT_POLICY_CACHE_MS?: string; + GRIDPILOT_POLICY_PATH?: string; + GRIDPILOT_OPERATIONAL_MODE?: string; + GRIDPILOT_FEATURES_JSON?: string; + GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string; + GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string; + + // Authorization + GRIDPILOT_AUTHZ_CACHE_MS?: string; + GRIDPILOT_USER_ROLES_JSON?: string; + } + } +} + +export {}; \ No newline at end of file diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 000000000..0f1b3a560 --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,62 @@ +export type ApiPersistence = 'postgres' | 'inmemory'; + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + return value !== '0' && value.toLowerCase() !== 'false'; +} + +function isSet(value: string | undefined): boolean { + return value !== undefined; +} + +function readLower(name: string): string | undefined { + const raw = process.env[name]; + if (raw === undefined) return undefined; + return raw.toLowerCase(); +} + +function requireOneOf(name: string, value: string, allowed: readonly T[]): T { + if ((allowed as readonly string[]).includes(value)) { + return value as T; + } + + const valid = allowed.join(', '); + throw new Error(`Invalid ${name}: "${value}". Must be one of: ${valid}`); +} + +/** + * Controls whether the API uses Postgres or runs in-memory. + * + * If `GRIDPILOT_API_PERSISTENCE` is set, it must be `postgres|inmemory`. + * Otherwise, it falls back to: `postgres` when `DATABASE_URL` exists, else `inmemory`. + */ +export function getApiPersistence(): ApiPersistence { + const configured = readLower('GRIDPILOT_API_PERSISTENCE'); + if (configured) { + return requireOneOf('GRIDPILOT_API_PERSISTENCE', configured, ['postgres', 'inmemory'] as const); + } + + return process.env.DATABASE_URL ? 'postgres' : 'inmemory'; +} + +/** + * Keep bootstrap on by default; tests can disable explicitly. + * + * `GRIDPILOT_API_BOOTSTRAP` uses "truthy" parsing: + * - false when unset / "0" / "false" + * - true otherwise + */ +export function getEnableBootstrap(): boolean { + const raw = process.env.GRIDPILOT_API_BOOTSTRAP; + if (raw === undefined) return true; + return isTruthyEnv(raw); +} + +/** + * When set, the API will generate `openapi.json` and optionally reduce logging noise. + * + * Matches previous behavior: any value (even "0") counts as enabled if the var is present. + */ +export function getGenerateOpenapi(): boolean { + return isSet(process.env.GENERATE_OPENAPI); +} \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 833a943b4..5de0e96c1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,8 +11,11 @@ import { AuthenticationGuard } from './domain/auth/AuthenticationGuard'; import { AuthorizationGuard } from './domain/auth/AuthorizationGuard'; import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard'; +import { getGenerateOpenapi } from './env'; + async function bootstrap() { - const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined); + const generateOpenapi = getGenerateOpenapi(); + const app = await NestFactory.create(AppModule, generateOpenapi ? { logger: false } : undefined); // Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001), // and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials. @@ -64,7 +67,7 @@ async function bootstrap() { SwaggerModule.setup('api/docs', app as any, document); // Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set - if (process.env.GENERATE_OPENAPI) { + if (generateOpenapi) { const outputPath = join(__dirname, '../openapi.json'); writeFileSync(outputPath, JSON.stringify(document, null, 2)); console.log(`✅ OpenAPI spec generated at: ${outputPath}`); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 3662362f7..62b66a860 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -50,6 +50,7 @@ ], "extends": "../../tsconfig.base.json", "include": [ - "src/**/*" + "src/**/*", + "src/**/*.d.ts" ] } \ No newline at end of file diff --git a/apps/website/env.d.ts b/apps/website/env.d.ts index a2d98855d..726f3356e 100644 --- a/apps/website/env.d.ts +++ b/apps/website/env.d.ts @@ -47,8 +47,33 @@ declare module 'react/compiler-runtime' { declare global { namespace NodeJS { interface ProcessEnv { + NODE_ENV?: 'development' | 'production' | 'test'; + + // Website (public, exposed to browser) NEXT_PUBLIC_GRIDPILOT_MODE?: 'pre-launch' | 'alpha'; + NEXT_PUBLIC_SITE_URL?: string; + NEXT_PUBLIC_API_BASE_URL?: string; + NEXT_PUBLIC_SITE_NAME?: string; + NEXT_PUBLIC_SUPPORT_EMAIL?: string; + NEXT_PUBLIC_SPONSOR_EMAIL?: string; + NEXT_PUBLIC_LEGAL_COMPANY_NAME?: string; + NEXT_PUBLIC_LEGAL_VAT_ID?: string; + NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY?: string; + NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS?: string; + NEXT_PUBLIC_DISCORD_URL?: string; NEXT_PUBLIC_X_URL?: string; + NEXT_TELEMETRY_DISABLED?: string; + + // Website (server-only) + API_BASE_URL?: string; + + // Vercel KV (server-only) + KV_REST_API_URL?: string; + KV_REST_API_TOKEN?: string; + + // Build/CI toggles (server-only) + CI?: string; + DOCKER?: string; } } } diff --git a/apps/website/lib/config/env.ts b/apps/website/lib/config/env.ts new file mode 100644 index 000000000..32d4c1570 --- /dev/null +++ b/apps/website/lib/config/env.ts @@ -0,0 +1,96 @@ +import { z } from 'zod'; + +const urlOptional = z.string().url().optional(); +const stringOptional = z.string().optional(); + +const publicEnvSchema = z.object({ + NEXT_PUBLIC_GRIDPILOT_MODE: z.enum(['pre-launch', 'alpha']).optional(), + NEXT_PUBLIC_SITE_URL: urlOptional, + NEXT_PUBLIC_API_BASE_URL: urlOptional, + + NEXT_PUBLIC_SITE_NAME: stringOptional, + NEXT_PUBLIC_SUPPORT_EMAIL: stringOptional, + NEXT_PUBLIC_SPONSOR_EMAIL: stringOptional, + + NEXT_PUBLIC_LEGAL_COMPANY_NAME: stringOptional, + NEXT_PUBLIC_LEGAL_VAT_ID: stringOptional, + NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY: stringOptional, + NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS: stringOptional, + + NEXT_PUBLIC_DISCORD_URL: stringOptional, + NEXT_PUBLIC_X_URL: stringOptional, +}); + +const serverEnvSchema = z.object({ + API_BASE_URL: urlOptional, + KV_REST_API_URL: urlOptional, + KV_REST_API_TOKEN: stringOptional, + + CI: stringOptional, + DOCKER: stringOptional, + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), +}); + +export type WebsitePublicEnv = z.infer; +export type WebsiteServerEnv = z.infer; + +function formatZodIssues(issues: z.ZodIssue[]): string { + return issues + .map((issue) => { + const path = issue.path.join('.') || '(root)'; + return `${path}: ${issue.message}`; + }) + .join('; '); +} + +/** + * Parses Website env vars (server-side safe). + * Only validates the variables we explicitly support. + */ +export function getWebsiteServerEnv(): WebsiteServerEnv { + const result = serverEnvSchema.safeParse(process.env); + if (!result.success) { + throw new Error(`Invalid website server env: ${formatZodIssues(result.error.issues)}`); + } + return result.data; +} + +/** + * Parses Website public env vars (safe on both server + client). + * Note: on the client, only `NEXT_PUBLIC_*` vars are actually present. + */ +export function getWebsitePublicEnv(): WebsitePublicEnv { + const result = publicEnvSchema.safeParse(process.env); + if (!result.success) { + throw new Error(`Invalid website public env: ${formatZodIssues(result.error.issues)}`); + } + return result.data; +} + +export function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + return value !== '0' && value.toLowerCase() !== 'false'; +} + +/** + * Matches the semantics used in [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6). + */ +export function isTestLikeEnvironment(): boolean { + const { NODE_ENV, CI, DOCKER } = getWebsiteServerEnv(); + return NODE_ENV === 'test' || CI === 'true' || DOCKER === 'true'; +} + +export function isProductionEnvironment(): boolean { + return getWebsiteServerEnv().NODE_ENV === 'production'; +} + +export function isKvConfigured(): boolean { + const { KV_REST_API_URL, KV_REST_API_TOKEN } = getWebsiteServerEnv(); + return Boolean(KV_REST_API_URL && KV_REST_API_TOKEN); +} + +export function assertKvConfiguredInProduction(): void { + if (isProductionEnvironment() && !isKvConfigured()) { + throw new Error('Missing KV_REST_API_URL/KV_REST_API_TOKEN in production environment'); + } +} \ No newline at end of file diff --git a/apps/website/lib/rate-limit.ts b/apps/website/lib/rate-limit.ts index ddc9fc813..72cd58a05 100644 --- a/apps/website/lib/rate-limit.ts +++ b/apps/website/lib/rate-limit.ts @@ -1,8 +1,11 @@ +import { assertKvConfiguredInProduction, isKvConfigured, isProductionEnvironment } from './config/env'; + const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds const MAX_REQUESTS_PER_WINDOW = 5; const RATE_LIMIT_PREFIX = 'ratelimit:signup:'; -const isDev = !process.env.KV_REST_API_URL; +// Dev fallback: only allowed outside production. +const isDev = !isProductionEnvironment() && !isKvConfigured(); // In-memory fallback for development const devRateLimits = new Map(); @@ -49,6 +52,8 @@ export async function checkRateLimit(identifier: string): Promise<{ } // Production: Use Vercel KV + assertKvConfiguredInProduction(); + const { kv } = await import('@vercel/kv'); const key = `${RATE_LIMIT_PREFIX}${identifier}`; diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index 4bbd25ade..f32882f8d 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -1,9 +1,20 @@ -import { apiClient } from '@/lib/apiClient'; +import { ApiClient } from '@/lib/api'; import type { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel'; import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; import type { LeagueMembership } from '@/lib/types/LeagueMembership'; +let cachedLeaguesApiClient: LeaguesApiClient | undefined; + +function getDefaultLeaguesApiClient(): LeaguesApiClient { + if (cachedLeaguesApiClient) return cachedLeaguesApiClient; + + const api = new ApiClient(getWebsiteApiBaseUrl()); + cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient; + return cachedLeaguesApiClient; +} + export class LeagueMembershipService { // In-memory cache for memberships (populated via API calls) private static leagueMemberships = new Map(); @@ -11,7 +22,7 @@ export class LeagueMembershipService { constructor(private readonly leaguesApiClient?: LeaguesApiClient) {} private getClient(): LeaguesApiClient { - return (this.leaguesApiClient ?? (apiClient as any).leagues) as LeaguesApiClient; + return this.leaguesApiClient ?? getDefaultLeaguesApiClient(); } async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { @@ -45,7 +56,7 @@ export class LeagueMembershipService { */ static async fetchLeagueMemberships(leagueId: string): Promise { try { - const result = await apiClient.leagues.getMemberships(leagueId); + const result = await getDefaultLeaguesApiClient().getMemberships(leagueId); const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({ id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it leagueId, diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index e28d49219..2a7dc4438 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -73,6 +73,7 @@ "hooks/", "lib/", "next-env.d.ts", + "env.d.ts", "types/", "utilities/", ".next/types/**/*.ts" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a417b2105..329a1d158 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,7 +17,7 @@ services: [ "sh", "-lc", - "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps (slow first time)\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", + "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps (slow first time)\"; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", ] networks: - gridpilot-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 96e5e6bd4..64a477e22 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -15,7 +15,7 @@ services: [ "sh", "-lc", - "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", + "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps\"; rm -rf apps/api/node_modules apps/website/node_modules; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", ] networks: - gridpilot-test-network @@ -26,12 +26,12 @@ services: environment: - NODE_ENV=test ports: - - "3001:3000" + - "3101:3000" command: [ "sh", "-lc", - "node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3000'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"", + "node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3100'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"", ] networks: - gridpilot-test-network @@ -59,9 +59,9 @@ services: - DOCKER=true - NEXT_PUBLIC_GRIDPILOT_MODE=alpha - API_BASE_URL=http://api:3000 - - NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 + - NEXT_PUBLIC_API_BASE_URL=http://localhost:3101 ports: - - "3000:3000" + - "3100:3000" volumes: - ./:/app - test_node_modules:/app/node_modules diff --git a/docs/TESTS.md b/docs/TESTS.md index 78d4d6b24..8651158d0 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -273,60 +273,49 @@ This pattern applies equally to integration tests (with real database operations --- -## Docker E2E Setup +## Docker-Based Tests (Website ↔ API Wiring) -### Architecture +This repo uses Docker in two different ways: -E2E tests run against a full stack orchestrated by `docker-compose.test.yml`: +1) **Website ↔ API smoke (wiring validation)** + - Orchestrated by [`docker-compose.test.yml`](docker-compose.test.yml:1) at the repo root. + - Runs: + - Website in Docker (Next.js dev server) + - An API mock server in Docker (Node HTTP server) + - Goal: catch misconfigured hostnames/ports/env vars and CORS issues that only show up in Dockerized setups. -```yaml -services: - postgres: - image: postgres:16 - environment: - POSTGRES_DB: gridpilot_test - POSTGRES_USER: test - POSTGRES_PASSWORD: test +2) **Hosted-session automation E2E (fixture-driven automation)** + - Orchestrated by `docker/docker-compose.e2e.yml` (separate stack; documented later in this file). + - Goal: validate Playwright-driven automation against HTML fixtures. - redis: - image: redis:7-alpine +### Website ↔ API smoke: how to run - web-api: - build: ./src/apps/web-api - depends_on: - - postgres - - redis - environment: - DATABASE_URL: postgres://test:test@postgres:5432/gridpilot_test - REDIS_URL: redis://redis:6379 - ports: - - "3000:3000" -``` +Run: +- `npm run test:docker:website` (see [`package.json`](package.json:92)) -### Execution Flow +What it does (in order): +- Installs deps in a dedicated Docker volume (`npm run docker:test:deps`) +- Starts the test stack (`npm run docker:test:up`) +- Waits for readiness (`npm run docker:test:wait`) +- Runs Playwright smoke tests (`npm run smoke:website:docker`) -1. **Start Services:** `docker compose -f docker-compose.test.yml up -d` -2. **Run Migrations:** `npm run migrate:test` (seeds database) -3. **Execute Tests:** Playwright targets `http://localhost:3000` -4. **Teardown:** `docker compose -f docker-compose.test.yml down -v` +Ports used: +- Website: `http://localhost:3100` +- API mock: `http://localhost:3101` -### Environment Setup +Key contract: +- Website must resolve the API base URL via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6). +- The website’s HTTP client uses `credentials: 'include'`, so the API must support CORS-with-credentials (implemented for the real API in [`bootstrap()`](apps/api/src/main.ts:14)). -```bash -# tests/e2e/setup.ts -export async function globalSetup() { - // Wait for web-api to be ready - await waitForService('http://localhost:3000/health'); - - // Run database migrations - await runMigrations(); -} +### “Mock vs Real” (Website & API) -export async function globalTeardown() { - // Stop Docker Compose services - await exec('docker compose -f docker-compose.test.yml down -v'); -} -``` +- The Website does **not** have a runtime flag like `AUTOMATION_MODE`. +- “Mock vs real” for the Website is **only** which API base URL it uses: + - Browser: `NEXT_PUBLIC_API_BASE_URL` + - Server: `API_BASE_URL` (preferred in Docker) or `NEXT_PUBLIC_API_BASE_URL` fallback + +In the Docker smoke stack, “mock API” means the Node HTTP server in [`docker-compose.test.yml`](docker-compose.test.yml:24). +In Docker dev/prod, “real API” means the NestJS app started from [`bootstrap()`](apps/api/src/main.ts:14), and “real vs in-memory” persistence is controlled by `GRIDPILOT_API_PERSISTENCE` in [`AppModule`](apps/api/src/app.module.ts:25). --- diff --git a/package.json b/package.json index 9ae3e8d2a..f2b9056bf 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,8 @@ "docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f", "docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps", "docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website", - "docker:test:down": "docker-compose -p gridpilot-test -f docker-compose.test.yml down -v", - "docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3001/health','api'); await wait('http://localhost:3000','website');})();\"", + "docker:test:down": "sh -lc \"docker-compose -p gridpilot-test -f docker-compose.test.yml down -v --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fsv || true\"", + "docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3101/health','api'); await wait('http://localhost:3100','website');})();\"", "smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts", "test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; npm run smoke:website:docker\"", "dom:process": "npx tsx scripts/dom-export/processWorkflows.ts", diff --git a/playwright.website.config.ts b/playwright.website.config.ts index 7bb0e98a3..57f37ddf6 100644 --- a/playwright.website.config.ts +++ b/playwright.website.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ // Base URL for the website use: { - baseURL: 'http://localhost:3000', + baseURL: process.env.DOCKER_SMOKE ? 'http://localhost:3100' : 'http://localhost:3000', screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', diff --git a/tests/env.d.ts b/tests/env.d.ts new file mode 100644 index 000000000..70012d902 --- /dev/null +++ b/tests/env.d.ts @@ -0,0 +1,34 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV?: 'development' | 'production' | 'test'; + + // CI providers / generic CI + CI?: string; + CONTINUOUS_INTEGRATION?: string; + GITHUB_ACTIONS?: string; + GITLAB_CI?: string; + CIRCLECI?: string; + TRAVIS?: string; + JENKINS_URL?: string; + BUILDKITE?: string; + TF_BUILD?: string; + + // Playwright / smoke + DOCKER_SMOKE?: string; + + // E2E toggles + HOSTED_REAL_E2E?: '0' | '1'; + COMPANION_FIXTURE_HOSTED?: string; + + // Electron (smoke harness) + ELECTRON_EXECUTABLE_PATH?: string; + + // Headless heuristics + HEADLESS?: string; + DISPLAY?: string; + } + } +} + +export {}; \ No newline at end of file diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 4a99c8839..b319ea9be 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.base.json", "include": [ + "tests/env.d.ts", "tests/**/*.ts", "tests/**/*.tsx" ],