From 176edd9fedadd47dce74dbf38d25fec9901bebaf Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 21 Apr 2026 01:48:17 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20stage=202=20=E2=80=94=20auth-gated?= =?UTF-8?q?=20pages=20(11=20tabs=20=C3=97=202=20viewports,=2022=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second containerized instance of the app with Turnstile + SMTP disabled so Playwright can log in without a bot challenge. - docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares postgres + pgdata with main so seeded test users (*@ped-ai.test) persist. - Test user: e2e-user@ped-ai.test (created once via /api/auth/register against the e2e container — SMTP is off so register auto-verifies). - Tests log in once per worker via /api/auth/login (module-scoped token cache) then inject the ped_auth cookie into each test's browser context. This avoids the 10-per-15-min login rate-limit. - Mobile viewport opens the sidebar via #btn-menu-toggle before clicking tab buttons (which are hidden behind the hamburger <=768px). Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning, dictation, settings, calculators, faq + landing-page-after-login. Each test clicks the tab, waits for the lazy component to render (>100 chars), and asserts a known anchor string is present. Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs + 11 desktop + 11 mobile auth-gated). --- docker-compose.e2e.yml | 40 +++++++++++ e2e/tests/auth-gated-smoke.spec.js | 107 +++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docker-compose.e2e.yml create mode 100644 e2e/tests/auth-gated-smoke.spec.js diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..7d1664d --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,40 @@ +# E2E test environment — runs a second instance of the app on port 3553 with +# Turnstile disabled so Playwright can log in without the bot challenge. +# Shares the postgres + pgdata volume with production so seeded e2e test users +# (email pattern *@ped-ai.test) persist across test runs. +# +# Bring up with: +# docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e +# +# Tear down with: +# docker compose -f docker-compose.yml -f docker-compose.e2e.yml down pediatric-scribe-e2e + +services: + pediatric-scribe-e2e: + build: . + image: ped-ai-local:latest + ports: + - "127.0.0.1:3553:3000" + env_file: + - .env + environment: + # Disable Turnstile bot-check so tests can log in without a challenge + TURNSTILE_SECRET_KEY: "" + # Disable SMTP so register auto-verifies the user and returns a session + SMTP_HOST: "" + volumes: + - scribe-logs-e2e:/app/data/logs + depends_on: + postgres: + condition: service_healthy + container_name: pediatric-ai-scribe-e2e + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s + +volumes: + scribe-logs-e2e: diff --git a/e2e/tests/auth-gated-smoke.spec.js b/e2e/tests/auth-gated-smoke.spec.js new file mode 100644 index 0000000..aa259c9 --- /dev/null +++ b/e2e/tests/auth-gated-smoke.spec.js @@ -0,0 +1,107 @@ +// Smoke tests for pages behind the auth wall. Runs against the separate +// `pediatric-ai-scribe-e2e` container (port 3553 on host, 3000 internal) which +// has TURNSTILE_SECRET_KEY="" + SMTP_HOST="" so tests can log in without a +// bot challenge and register auto-verifies. +// +// Each test logs in via the API (no UI interaction needed) and injects the +// session cookie into the browser context. + +const { test, expect } = require('@playwright/test'); + +const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000'; +const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553'; // only used when running Playwright on host +const E2E_BASE = process.env.E2E_AUTH_BASE_URL || E2E_BASE_INTERNAL; + +const TEST_EMAIL = 'e2e-user@ped-ai.test'; +const TEST_PASSWORD = 'E2E-testPassword123!'; + +// Module-scoped token cache — one login per Playwright worker (config uses +// workers:1, so effectively one login for the whole run). Without this we +// hit the 10/15min login rate-limit on the first pass. +let _tokenCache = null; + +async function getAuthToken(request) { + if (_tokenCache) return _tokenCache; + const r = await request.post(E2E_BASE + '/api/auth/login', { + data: { email: TEST_EMAIL, password: TEST_PASSWORD }, + }); + if (!r.ok()) { + const text = await r.text(); + throw new Error(`Login failed (status ${r.status()}): ${text}`); + } + const body = await r.json(); + if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body)); + _tokenCache = body.token; + return _tokenCache; +} + +async function loginAs(context, request) { + const token = await getAuthToken(request); + const url = new URL(E2E_BASE); + await context.addCookies([ + { + name: 'ped_auth', + value: token, + domain: url.hostname, + path: '/', + httpOnly: true, + secure: false, + sameSite: 'Lax', + }, + ]); +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test.describe('Auth-gated pages — main tabs', () => { + test.beforeEach(async ({ context, request }) => { + await loginAs(context, request); + }); + + test('Landing page shows tab navigation after login', async ({ page }) => { + await page.goto(E2E_BASE + '/'); + await expect(page.locator('button.tab-btn').first()).toBeVisible({ timeout: 15000 }); + }); + + // Each auth-gated tab test: activate the tab, assert its container becomes + // visible + contains the expected anchor string. We use getTabPanel helpers + // because components load lazily from /components/.html. + const tabs = [ + { name: 'encounter', anchor: /New Encounter|encounter|SOAP/i }, + { name: 'wellvisit', anchor: /Well Visit|well visit/i }, + { name: 'chart', anchor: /Chart|visits|patients/i }, + { name: 'vaxschedule', anchor: /Vaccine|schedule|dose/i }, + { name: 'catchup', anchor: /Catch-up|catch up|schedule/i }, + { name: 'learning', anchor: /Learning|quiz|topic/i }, + { name: 'dictation', anchor: /Dictation|record|transcrib/i }, + { name: 'settings', anchor: /Setting|profile|preferences|account/i }, + { name: 'calculators', anchor: /Pediatric Calculator|BP Percentile|BMI/i }, + { name: 'faq', anchor: /FAQ|question|answer/i }, + ]; + + for (const { name, anchor } of tabs) { + test(`${name} tab loads content`, async ({ page, viewport }) => { + await page.goto(E2E_BASE + '/'); + // On mobile the sidebar is hidden behind a hamburger. Open it so the + // tab buttons become interactable. + if (viewport && viewport.width <= 768) { + await page.click('#btn-menu-toggle').catch(() => {}); + } + await page.waitForSelector('button.tab-btn', { timeout: 15000 }); + const tabBtn = page.locator(`button.tab-btn[data-tab="${name}"]`); + const isHidden = await tabBtn.evaluate((el) => el.classList.contains('hidden')).catch(() => true); + test.skip(isHidden, `Tab "${name}" is hidden for this user role`); + await tabBtn.click(); + // Wait for lazy component load to complete + await page.waitForFunction( + (t) => { + const el = document.getElementById(t + '-tab'); + return el && el.classList.contains('active') && el.innerHTML.trim().length > 100; + }, + name, + { timeout: 15000 } + ); + await expect(page.locator(`#${name}-tab`)).toContainText(anchor); + }); + } +});