diff --git a/.gitignore b/.gitignore index ceefd36..54424c0 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,8 @@ htmlcov/ coverage.xml *.cover .hypothesis/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# Playwright artifacts +playwright-report/ +test-results/ diff --git a/README.md b/README.md index 88bc0f6..3a544fb 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,20 @@ Or in Docker: docker compose exec api pytest --cov=app --cov-report=term-missing ``` +### End-to-End Testing +Browser e2e tests use pytest plus Playwright. The recommended path is the containerized runner, which joins the same Compose network as the app and opens the web service at `http://web`. + +```bash +docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web +docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e +``` + +Authenticated browser tests are skipped unless `E2E_ADMIN_USERNAME` and `E2E_ADMIN_PASSWORD` are supplied. See [`tests/e2e/README.md`](./tests/e2e/README.md) for credential examples, host-run instructions, and guidance for adding specs. + +The e2e Compose override uses a separate MySQL container and volume, so tests do not use the normal dev/prod database configured in `.env`. It is still a real MySQL database, but isolated for e2e. + +E2e reports are written to `test-results/e2e-report.html` and `test-results/e2e-junit.xml`. + ## Additional Features ### Email Notifications diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..8c0c427 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,79 @@ +services: + db: + container_name: ppr_e2e_db + build: + context: . + dockerfile: tests/e2e/mysql.Dockerfile + image: pprdev-e2e-db + environment: + MYSQL_ROOT_PASSWORD: e2e_root_password + MYSQL_DATABASE: ppr_e2e + MYSQL_USER: ppr_e2e + MYSQL_PASSWORD: ppr_e2e_password + volumes: + - ppr_e2e_mysql_data:/var/lib/mysql + + api: + container_name: ppr_e2e_api + ports: + - "${E2E_API_PORT_EXTERNAL:-18002}:8000" + environment: + DB_HOST: db + DB_PORT: 3306 + DB_NAME: ppr_e2e + DB_USER: ppr_e2e + DB_PASSWORD: ppr_e2e_password + BASE_URL: http://web + MAIL_HOST: 127.0.0.1 + MAIL_PORT: 1 + MAIL_USERNAME: e2e + MAIL_PASSWORD: e2e + MAIL_FROM: e2e@example.com + MAIL_FROM_NAME: PPR E2E + + web: + container_name: ppr_e2e_web + ports: + - "${E2E_WEB_PORT_EXTERNAL:-18055}:80" + environment: + BASE_URL: "" + command: > + sh -c "if [ -z \"$${BASE_URL}\" ]; then API_BASE='/api/v1'; else API_BASE=\"$${BASE_URL}/api/v1\"; fi; + printf 'window.PPR_CONFIG = { apiBase: \"%s\" };' \"$${API_BASE}\" > /usr/share/nginx/html/config.js; + nginx -g 'daemon off;'" + + e2e: + build: + context: . + dockerfile: tests/e2e/Dockerfile + working_dir: /workspace + volumes: + - .:/workspace + environment: + E2E_BASE_URL: http://web + E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-} + E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-} + E2E_HEALTH_URL: http://api:8000/health + E2E_ARTIFACT_UID: ${E2E_ARTIFACT_UID:-1000} + E2E_ARTIFACT_GID: ${E2E_ARTIFACT_GID:-1000} + PYTHONDONTWRITEBYTECODE: "1" + depends_on: + - web + networks: + - public_network + command: > + bash -lc "mkdir -p test-results && + python tests/e2e/wait_for_web.py && + pytest tests/e2e + --browser chromium + --tracing=retain-on-failure + --screenshot=only-on-failure + --junitxml=test-results/e2e-junit.xml + --html=test-results/e2e-report.html + --self-contained-html; + status=$$?; + chown -R \"$${E2E_ARTIFACT_UID}:$${E2E_ARTIFACT_GID}\" test-results || true; + exit $$status" + +volumes: + ppr_e2e_mysql_data: diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..4226ce0 --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy + +WORKDIR /workspace + +COPY tests/e2e/requirements.txt /tmp/e2e-requirements.txt +RUN pip install --no-cache-dir -r /tmp/e2e-requirements.txt diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..904ccee --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,90 @@ +# End-to-End Tests + +The e2e suite uses pytest plus Playwright. The preferred path is the containerized runner, which joins the same Docker Compose network as the web app and opens `http://web`. + +## Containerized Run + +```bash +docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d db api web +docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e +``` + +The e2e override sets `E2E_BASE_URL=http://web`, makes the web container generate relative API config for browser-side requests, and forces the API container to use an e2e-only Compose database. + +The e2e database is a real MySQL server, but it is isolated from the normal dev database: + +- DB container: `ppr_e2e_db` +- API container: `ppr_e2e_api` +- Web container: `ppr_e2e_web` +- Database name: `ppr_e2e` +- Volume: `pprdev_ppr_e2e_mysql_data` + +The e2e DB image is plain `mysql:8.0`, so the API should see a fresh empty database and create schema through Alembic migrations instead of stamping an older `db-init` schema. + +Authenticated tests are skipped unless credentials are supplied: + +```bash +E2E_ADMIN_USERNAME=admin \ +E2E_ADMIN_PASSWORD=admin123 \ +docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e +``` + +## Rebuild The Test Image + +```bash +docker compose -f docker-compose.yml -f docker-compose.e2e.yml build e2e +``` + +## Test Evidence + +Containerized runs write evidence to `test-results/`: + +- `test-results/e2e-report.html` for a human-readable report +- `test-results/e2e-junit.xml` for CI systems +- Playwright traces and screenshots on failures + +If the report files are not owned by your host user, pass your UID/GID: + +```bash +E2E_ARTIFACT_UID=$(id -u) \ +E2E_ARTIFACT_GID=$(id -g) \ +docker compose -f docker-compose.yml -f docker-compose.e2e.yml run --rm e2e +``` + +## Host Run + +Running on the host is still supported for quick debugging if Python and Playwright are installed locally. + +First-time host setup: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r backend/requirements.txt +pip install -r tests/e2e/requirements.txt +python -m playwright install --with-deps chromium +``` + +Run against a host-exposed web port: + +```bash +E2E_BASE_URL=http://localhost:8082 pytest tests/e2e +``` + +Run authenticated host tests: + +```bash +E2E_BASE_URL=http://localhost:8082 \ +E2E_ADMIN_USERNAME=admin \ +E2E_ADMIN_PASSWORD=admin123 \ +pytest tests/e2e +``` + +## Adding Tests + +Put browser specs in `tests/e2e/test_*.py`. Start with user-visible behavior and stable selectors: + +- Navigate through the same URLs users open. +- Prefer roles and labels, such as `get_by_role()` and `get_by_label()`. +- Use API setup only when a test needs specific records to exist. +- Keep specs independent so they can run in any order. diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..3f946fe --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,27 @@ +import os + +from playwright.sync_api import expect + + +BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/") +ADMIN_USERNAME = os.getenv("E2E_ADMIN_USERNAME") +ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD") + + +def app_url(path): + return f"{BASE_URL}/{path.lstrip('/')}" + + +def login_as_admin(page): + if page.locator("#login-username").is_visible(): + page.locator("#login-username").fill(ADMIN_USERNAME) + page.locator("#login-password").fill(ADMIN_PASSWORD) + page.locator("#login-btn").click() + expect(page.locator("#current-user")).to_have_text(ADMIN_USERNAME) + if page.get_by_role("heading", name="Login").count() > 0: + expect(page.get_by_role("heading", name="Login")).to_be_hidden() + + +def skip_without_admin_credentials(pytest): + if not ADMIN_USERNAME or not ADMIN_PASSWORD: + pytest.skip("Set E2E_ADMIN_USERNAME and E2E_ADMIN_PASSWORD to run authenticated e2e tests") diff --git a/tests/e2e/mysql.Dockerfile b/tests/e2e/mysql.Dockerfile new file mode 100644 index 0000000..7197596 --- /dev/null +++ b/tests/e2e/mysql.Dockerfile @@ -0,0 +1 @@ +FROM mysql:8.0 diff --git a/tests/e2e/requirements.txt b/tests/e2e/requirements.txt new file mode 100644 index 0000000..111a245 --- /dev/null +++ b/tests/e2e/requirements.txt @@ -0,0 +1,3 @@ +playwright==1.45.0 +pytest-playwright>=0.5,<0.6 +pytest-html>=4,<5 diff --git a/tests/e2e/test_admin_menus.py b/tests/e2e/test_admin_menus.py new file mode 100644 index 0000000..a4d31f1 --- /dev/null +++ b/tests/e2e/test_admin_menus.py @@ -0,0 +1,73 @@ +import re + +import pytest +from playwright.sync_api import expect + +from helpers import app_url, login_as_admin, skip_without_admin_credentials + + +def open_admin_dropdown(page): + page.locator("#adminDropdownBtn").click() + admin_menu = page.locator("#adminDropdownMenu") + expect(admin_menu).to_have_class(re.compile("active")) + return admin_menu + + +def open_admin_page(page): + skip_without_admin_credentials(pytest) + page.goto(app_url("/admin")) + expect(page).to_have_title(re.compile("PPR Admin Interface")) + expect(page.get_by_role("heading", name=re.compile("Swansea Tower"))).to_be_visible() + login_as_admin(page) + + +def test_actions_and_admin_dropdowns_toggle_exclusively(page): + open_admin_page(page) + + actions_menu = page.locator("#actionsDropdownMenu") + admin_menu = page.locator("#adminDropdownMenu") + + expect(actions_menu).not_to_have_class(re.compile("active")) + expect(admin_menu).not_to_have_class(re.compile("active")) + + page.locator("#actionsDropdownBtn").click() + expect(actions_menu).to_have_class(re.compile("active")) + expect(admin_menu).not_to_have_class(re.compile("active")) + expect(actions_menu.get_by_role("link", name=re.compile("New PPR"))).to_be_visible() + expect(actions_menu.get_by_role("link", name=re.compile("Book Out"))).to_be_visible() + expect(actions_menu.get_by_role("link", name=re.compile("Book In"))).to_be_visible() + expect(actions_menu.get_by_role("link", name=re.compile("Overflight"))).to_be_visible() + + admin_menu = open_admin_dropdown(page) + expect(actions_menu).not_to_have_class(re.compile("active")) + expect(admin_menu.get_by_role("link", name=re.compile("Admin View"))).to_be_visible() + expect(admin_menu.get_by_role("link", name=re.compile("ATC View"))).to_be_visible() + expect(admin_menu.get_by_role("link", name=re.compile("Reports"))).to_be_visible() + expect(admin_menu.get_by_role("link", name=re.compile("Drone Requests"))).to_be_visible() + expect(admin_menu.get_by_role("link", name=re.compile("Journal Log"))).to_be_visible() + + page.locator(".container").click() + expect(admin_menu).not_to_have_class(re.compile("active")) + + +def test_admin_menu_links_navigate_to_expected_pages(page): + open_admin_page(page) + + menu_expectations = [ + ("Admin View", re.compile(r"/admin$"), re.compile("PPR Admin Interface"), "title"), + ("ATC View", re.compile(r"/atc$"), re.compile("ATC Management Interface"), "title"), + ("Reports", re.compile(r"/reports$"), re.compile("PPR Reports"), "heading"), + ("Drone Requests", re.compile(r"/drone-requests$"), re.compile("Drone Flight Requests"), "heading"), + ("Journal Log", re.compile(r"/journal$"), re.compile("Journal Log"), "heading"), + ] + + for label, url_pattern, expected_text, assertion_type in menu_expectations: + page.goto(app_url("/admin")) + login_as_admin(page) + admin_menu = open_admin_dropdown(page) + admin_menu.get_by_role("link", name=re.compile(label)).click() + expect(page).to_have_url(url_pattern) + if assertion_type == "title": + expect(page).to_have_title(expected_text) + else: + expect(page.get_by_role("heading", name=expected_text)).to_be_visible() diff --git a/tests/e2e/test_drone_requests.py b/tests/e2e/test_drone_requests.py new file mode 100644 index 0000000..0ebf43d --- /dev/null +++ b/tests/e2e/test_drone_requests.py @@ -0,0 +1,64 @@ +import re +import json +from datetime import datetime + +import pytest +from playwright.sync_api import expect + +from helpers import app_url, login_as_admin, skip_without_admin_credentials + + +def drone_payload(operator_name): + return { + "operator_name": operator_name, + "operator_id": "E2E-OP", + "flyer_name": "E2E Remote Pilot", + "flyer_id": "E2E-FLYER", + "email": "drone-e2e@example.com", + "phone": "0123456789", + "flight_date": "2026-06-21", + "estimated_takeoff_time": "10:00", + "estimated_completion_time": "10:30", + "estimated_takeoff_at": "2026-06-21T10:00:00", + "estimated_completion_at": "2026-06-21T10:30:00", + "maximum_elevation_ft_amsl": 200, + "location_description": "E2E north apron survey", + "location_latitude": 51.623389, + "location_longitude": -4.069231, + "location_inside_frz": "yes", + "notes": "Created by Playwright e2e", + } + + +def test_drone_requests_requires_login_and_loads_queue(page): + skip_without_admin_credentials(pytest) + + page.goto(app_url("/drone-requests")) + + expect(page).to_have_title(re.compile("Drone Flight Requests")) + expect(page.get_by_role("heading", name="Drone Flight Requests")).to_be_visible() + expect(page.get_by_role("heading", name="Login")).to_be_visible() + + login_as_admin(page) + + expect(page.locator("#request-list-body")).not_to_contain_text("Loading requests...") + expect(page.locator("#request-count")).to_have_text(re.compile(r"\d+")) + + +def test_public_drone_request_appears_in_tower_queue(page): + skip_without_admin_credentials(pytest) + + operator_name = f"E2E Rotor Ops {datetime.utcnow().strftime('%H%M%S%f')}" + create_response = page.request.post( + app_url("/api/v1/drone-requests/public"), + data=json.dumps(drone_payload(operator_name)), + headers={"Content-Type": "application/json"}, + ) + assert create_response.ok, create_response.text() + created = create_response.json() + + page.goto(app_url("/drone-requests")) + login_as_admin(page) + + expect(page.locator("#request-list-body")).to_contain_text(created["reference_number"]) + expect(page.locator("#request-list-body")).to_contain_text(operator_name) diff --git a/tests/e2e/test_local_flight_workflow.py b/tests/e2e/test_local_flight_workflow.py new file mode 100644 index 0000000..e0f7364 --- /dev/null +++ b/tests/e2e/test_local_flight_workflow.py @@ -0,0 +1,56 @@ +from datetime import datetime + +import pytest +from playwright.sync_api import expect + +from helpers import app_url, login_as_admin, skip_without_admin_credentials + + +def local_flight_row(page, registration): + return page.locator("#local-flights-table-body tr").filter(has_text=registration) + + +def confirm_timestamp(page, button_text): + expect(page.locator("#timestampModal")).to_be_visible() + expect(page.locator("#timestamp-submit-btn")).to_contain_text(button_text) + page.locator("#timestamp-submit-btn").click() + expect(page.locator("#timestampModal")).to_be_hidden() + + +def test_operator_books_out_local_flight_records_touch_and_go_and_lands(page): + skip_without_admin_credentials(pytest) + registration = f"GE2E{datetime.utcnow().strftime('%H%M%S')}" + + page.goto(app_url("/admin")) + login_as_admin(page) + + page.locator("#actionsDropdownBtn").click() + page.locator("#actionsDropdownMenu").get_by_role("link", name="🛫 Book Out (L)").click() + + expect(page.locator("#localFlightModal")).to_be_visible() + page.locator("#local_registration").fill(registration) + page.locator("#local_type").fill("PA28") + page.locator("#local_pob").fill("1") + page.locator("#local_flight_type").select_option("LOCAL") + page.locator("#local_duration").fill("45") + page.locator("#local_notes").fill("E2E local flight lifecycle") + page.locator("#local-flight-form").get_by_role("button", name="🛫 Book Out").click() + + expect(page.locator("#localFlightModal")).to_be_hidden() + row = local_flight_row(page, registration) + expect(row).to_be_visible() + expect(row).to_contain_text("GROUND") + + row.get_by_role("button", name="TAKE OFF").click() + confirm_timestamp(page, "Confirm Takeoff") + expect(row).to_contain_text("LOCAL") + + row.get_by_role("button", name="T&G").click() + expect(page.locator("#circuitModal")).to_be_visible() + page.locator("#circuit-form").get_by_role("button", name="Record Circuit").click() + expect(page.locator("#circuitModal")).to_be_hidden() + expect(row.locator("td").nth(7)).to_have_text("1") + + row.get_by_role("button", name="LAND").click() + confirm_timestamp(page, "Confirm Landing") + expect(local_flight_row(page, registration)).to_have_count(0) diff --git a/tests/e2e/test_public_pages.py b/tests/e2e/test_public_pages.py new file mode 100644 index 0000000..80556be --- /dev/null +++ b/tests/e2e/test_public_pages.py @@ -0,0 +1,26 @@ +import os +import re + +from playwright.sync_api import expect + + +BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/") + + +def app_url(path): + return f"{BASE_URL}/{path.lstrip('/')}" + + +def test_flight_information_display_loads(page): + page.goto(app_url("/")) + + expect(page).to_have_title(re.compile("Swansea Airport - Arrivals & Departures")) + expect(page.get_by_role("heading", name="Flight Information")).to_be_visible() + + +def test_public_ppr_form_loads(page): + page.goto(app_url("/ppr.html")) + + expect(page).to_have_title(re.compile("Swansea PPR")) + expect(page.get_by_role("heading", name=re.compile("PPR Request"))).to_be_visible() + expect(page.get_by_role("button", name="Submit PPR Request")).to_be_visible() diff --git a/tests/e2e/test_public_ppr_submission.py b/tests/e2e/test_public_ppr_submission.py new file mode 100644 index 0000000..785b34f --- /dev/null +++ b/tests/e2e/test_public_ppr_submission.py @@ -0,0 +1,38 @@ +import os +from datetime import datetime + +from playwright.sync_api import expect + + +BASE_URL = os.getenv("E2E_BASE_URL", "http://localhost:8082").rstrip("/") + + +def app_url(path): + return f"{BASE_URL}/{path.lstrip('/')}" + + +def test_public_ppr_form_submits_successfully(page): + unique_registration = f"GE{datetime.utcnow().strftime('%H%M')}" + + page.goto(app_url("/ppr.html")) + + page.locator("#ac_reg").fill(unique_registration) + page.locator("#ac_type").fill("PA28") + page.locator("#ac_call").fill(unique_registration) + page.locator("#captain").fill("E2E Test Pilot") + page.locator("#in_from").fill("EGLL") + page.locator("#eta-date").fill("2026-06-21") + page.locator("#eta-time").select_option("10:00") + page.locator("#pob_in").fill("2") + page.locator("#fuel").select_option("100LL") + page.locator("#out_to").fill("EGFF") + page.locator("#etd-date").fill("2026-06-21") + page.locator("#etd-time").select_option("12:00") + page.locator("#pob_out").fill("2") + page.locator("#phone").fill("0123456789") + page.locator("#notes").fill("Submitted by Playwright e2e") + + page.get_by_role("button", name="Submit PPR Request").click() + + expect(page.locator("#success-message")).to_be_visible() + expect(page.locator("#success-message")).to_contain_text("PPR Request Submitted") diff --git a/tests/e2e/wait_for_web.py b/tests/e2e/wait_for_web.py new file mode 100644 index 0000000..58228fd --- /dev/null +++ b/tests/e2e/wait_for_web.py @@ -0,0 +1,24 @@ +import os +import time +import urllib.request + + +base_url = os.getenv("E2E_BASE_URL", "http://web").rstrip("/") +health_url = os.getenv("E2E_HEALTH_URL", f"{base_url}/") +deadline = time.time() + int(os.getenv("E2E_WEB_TIMEOUT_SECONDS", "120")) +last_error = None + +while time.time() < deadline: + try: + with urllib.request.urlopen(health_url, timeout=5) as response: + if response.status < 500: + break + except Exception as exc: + last_error = exc + time.sleep(2) +else: + raise SystemExit(f"Timed out waiting for {health_url}: {last_error}") + +with urllib.request.urlopen(f"{base_url}/", timeout=5) as response: + if response.status >= 500: + raise SystemExit(f"Web returned HTTP {response.status} at {base_url}/")