diff --git a/README.md b/README.md index ffe2a7c..75d85e8 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,32 @@ docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen ``` ### Testing +The backend API tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database with dependency overrides for database sessions and authenticated users. + +See [`backend/tests/README.md`](./backend/tests/README.md) for a module-by-module explanation of what the tests cover and why. + ```bash cd backend -pytest tests/ +pytest +``` + +Or, with the Docker development stack running: + +```bash +docker compose exec api pytest +``` + +To inspect API test coverage: + +```bash +cd backend +pytest --cov=app --cov-report=term-missing +``` + +Or in Docker: + +```bash +docker compose exec api pytest --cov=app --cov-report=term-missing ``` ## Additional Features @@ -293,4 +316,4 @@ docker-compose down To remove volumes (database data): ```bash docker-compose down -v -``` \ No newline at end of file +``` diff --git a/backend/app/api/endpoints/pprs.py b/backend/app/api/endpoints/pprs.py index eb28cda..880d2a0 100644 --- a/backend/app/api/endpoints/pprs.py +++ b/backend/app/api/endpoints/pprs.py @@ -7,7 +7,8 @@ from app.crud.crud_ppr import ppr as crud_ppr from app.crud.crud_journal import journal as crud_journal from app.crud.crud_arrival import arrival as crud_arrival from app.crud.crud_departure import departure as crud_departure -from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate, Journal +from app.schemas.ppr import PPR, PPRCreate, PPRUpdate, PPRStatus, PPRStatusUpdate +from app.schemas.journal import JournalEntryResponse from app.schemas.arrival import ArrivalCreate from app.schemas.departure import DepartureCreate from app.models.ppr import User @@ -397,7 +398,7 @@ async def cancel_ppr_public( return cancelled_ppr -@router.get("/{ppr_id}/journal", response_model=List[Journal]) +@router.get("/{ppr_id}/journal", response_model=List[JournalEntryResponse]) async def get_ppr_journal( ppr_id: int, db: Session = Depends(get_db), @@ -412,7 +413,7 @@ async def get_ppr_journal( detail="PPR record not found" ) - return crud_journal.get_by_ppr_id(db, ppr_id=ppr_id) + return crud_journal.get_ppr_journal(db, ppr_id=ppr_id) @router.post("/{ppr_id}/activate") diff --git a/backend/app/crud/crud_ppr.py b/backend/app/crud/crud_ppr.py index 018252c..c046848 100644 --- a/backend/app/crud/crud_ppr.py +++ b/backend/app/crud/crud_ppr.py @@ -212,6 +212,7 @@ class CRUDPPR: # Log the deletion in journal crud_journal.log_change( db, + EntityType.PPR, db_obj.id, f"PPR marked as DELETED (was {old_status.value})", user, diff --git a/backend/app/models/departure.py b/backend/app/models/departure.py index c58204a..47c3cdf 100644 --- a/backend/app/models/departure.py +++ b/backend/app/models/departure.py @@ -1,9 +1,7 @@ from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func -from sqlalchemy.ext.declarative import declarative_base from enum import Enum from datetime import datetime - -Base = declarative_base() +from app.db.session import Base class SubmissionSource(str, Enum): diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..a53e2f3 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = tests +python_files = test_*.py +pythonpath = . +addopts = -q +filterwarnings = + ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic\._internal\._config + ignore:Pydantic V1 style `@validator` validators are deprecated:DeprecationWarning:app\.schemas\..* + ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:app\.crud\..* + ignore:The `dict` method is deprecated; use `model_dump` instead:DeprecationWarning:pydantic\.main + ignore:The ``declarative_base\(\)`` function is now available as sqlalchemy\.orm\.declarative_base\(\):sqlalchemy.exc.MovedIn20Warning:app\.db\.session + ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib\.utils + ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:app\.main + ignore:\s*on_event is deprecated, use lifespan event handlers instead:DeprecationWarning:fastapi\.applications diff --git a/backend/requirements.txt b/backend/requirements.txt index 614571e..e3d5f06 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,8 +12,9 @@ email-validator==2.1.0 pydantic[email]==2.5.0 pydantic-settings==2.0.3 pytest==7.4.3 +pytest-cov==4.1.0 pytest-asyncio==0.21.1 httpx==0.25.2 redis==5.0.1 aiosmtplib==3.0.1 -jinja2==3.1.2 \ No newline at end of file +jinja2==3.1.2 diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000..956bc2a --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,199 @@ +# Backend API Test Guide + +This directory contains the backend API test suite. The tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database. The goal is to cover the business-critical API behaviour without relying on MySQL, Redis, SMTP, or a running browser. + +## How To Run + +From `backend/`: + +```bash +pytest +pytest --cov=app --cov-report=term-missing +``` + +From the project root with Docker Compose running: + +```bash +docker compose exec api pytest +docker compose exec api pytest --cov=app --cov-report=term-missing +``` + +## Shared Fixtures + +### `conftest.py` + +Sets up the test harness used by every module. + +What it does: +- Provides safe default environment variables before the app imports settings. +- Creates an in-memory SQLite database and overrides FastAPI's `get_db` dependency. +- Recreates all tables for every test so tests cannot leak state into each other. +- Patches SQLite primary-key handling for models that use `BigInteger` ids in production. +- Provides `client` for unauthenticated requests and `auth_client` for administrator/operator requests. +- Provides reusable PPR payload and factory fixtures. + +Why it exists: +- Keeps API tests fast, deterministic, and independent from Docker MySQL data. +- Lets tests exercise the real FastAPI routes, schemas, CRUD calls, and dependency overrides. + +## Test Modules + +### `test_app_health.py` + +Covers the simplest application-level endpoints. + +What it tests: +- `/` returns API metadata. +- `/health` reports a healthy application and database connection. + +Why it matters: +- These tests catch broken app imports, router setup problems, and database dependency regressions early. + +### `test_auth_api.py` + +Covers authentication and admin user-management routes. + +What it tests: +- Login rejects invalid credentials. +- Login returns a bearer token for a valid user. +- Admin users can create, list, update, and change passwords for users. +- Duplicate users and missing users return the expected errors. + +Why it matters: +- Auth is the gatekeeper for most operational endpoints. +- The admin user flow is also a good end-to-end check of password hashing, token creation, CRUD, and journal side effects. + +### `test_pprs_api.py` + +Covers the core PPR lifecycle. + +What it tests: +- PPR routes require authentication where appropriate. +- Authenticated users can create, read, update, patch, acknowledge, status-update, soft-delete, and audit PPRs. +- List filters work for status, dates, skip, and limit. +- Public PPR creation sends email and generates secure edit tokens. +- Public edit and cancel token flows work and reject invalid or processed requests. +- Activation creates an arrival and pending departure. +- Missing PPRs return 404. +- Invalid payloads return validation errors. + +Why it matters: +- PPRs are the central workflow in the system. +- These tests protect the operational state transitions that drive tower/admin views and audit history. + +### `test_public_api.py` + +Covers public read-only board and lookup endpoints. + +What it tests: +- Public arrivals and departures start empty. +- Today's PPRs, local flights, arrivals, and departures appear on public boards. +- Old or cancelled records are excluded. +- Public airport and aircraft lookups return seeded records. +- Short or invalid lookup queries return empty lists. + +Why it matters: +- Public boards and lookup helpers are user-facing and unauthenticated. +- These tests check that the public API exposes useful operational information without requiring login. + +### `test_flight_strip_apis.py` + +Covers authenticated flight-strip style CRUD endpoints. + +What it tests: +- Arrival lifecycle: create, list/filter, read, update, land, cancel, and not-found paths. +- Landing an arrival promotes a linked pending departure. +- Departure lifecycle: create, list/filter, update, takeoff/departure status, cancel, and not-found paths. +- Local flight lifecycle: create, list/filter, update, depart, land, special lists, cancel, and not-found paths. +- Overflight lifecycle: create, active/today lists, list/filter, update, mark inactive/QSY, cancel, and not-found paths. +- Movement records are created for real takeoff, landing, touch-and-go, and overflight events where relevant. + +Why it matters: +- These endpoints represent day-to-day tower strip operations. +- They also exercise important CRUD side effects: status timestamps, movements, linked departures, and journal entries. + +### `test_circuits_api.py` + +Covers circuit/touch-and-go records. + +What it tests: +- Circuits can be recorded for local flights. +- Circuits can be recorded for arrivals. +- Circuit list, lookup-by-flight, lookup-by-arrival, update, and delete work. +- Invalid circuit creation requests are rejected when neither or both parent ids are supplied. +- Missing circuits return 404. +- Recording a circuit creates a touch-and-go movement. + +Why it matters: +- Circuit traffic is a distinct operational pattern and feeds movement logging. +- The parent-id validation prevents ambiguous audit/movement records. + +### `test_movements_api.py` + +Covers movement listing, context lookup, and bulk paper-strip logging. + +What it tests: +- Movement list filters and single-record reads. +- Bulk movement context suggests matching PPRs and existing movements. +- Bulk logging can create and update PPR-linked arrivals. +- Bulk logging can create unmatched arrival and departure records. +- Bulk logging handles local flight strips with takeoff, landing, duration, and circuits. +- Bulk logging handles overflight strips and updates existing overflight records. +- Invalid bulk-log requests return helpful 400 errors. + +Why it matters: +- Bulk movement logging is one of the densest workflows in the API. +- These tests protect the behaviour that translates paper-strip data into PPR, arrival, departure, local flight, overflight, movement, and journal records. + +### `test_drone_requests_api.py` + +Covers drone flight request workflows. + +What it tests: +- Public drone request creation generates references/tokens and sends confirmation email. +- Public edit and cancel token flows work. +- Processed drone requests cannot be edited or cancelled publicly. +- Authenticated users can list, read, update, status-update, comment on, and audit drone requests. +- Missing records and invalid payloads return expected errors. + +Why it matters: +- Drone requests are a newer workflow with public and authenticated surfaces. +- The tests protect email notification, public token, status, comment, and journal behaviour. + +### `test_public_book_api.py` + +Covers the optional public booking portal. + +What it tests: +- Public booking rejects requests when disabled. +- Public local flight booking creates a public-submitted local flight. +- Public circuit recording creates a circuit and touch-and-go movement. +- Public departure and arrival booking create public-submitted records with pilot emails. +- Invalid public booking payloads return validation errors. + +Why it matters: +- Public booking is controlled by configuration and should be safe to disable. +- When enabled, it creates operational records without authentication, so validation and submitted-via metadata matter. + +### `test_journal_api.py` + +Covers generic audit/journal endpoints. + +What it tests: +- Journal search filters by date, entity type, entity id, and user. +- Invalid entity types are rejected. +- User journal and entity journal endpoints return entries and summary counts. + +Why it matters: +- The journal is the audit trail across PPRs, flights, users, drone requests, and movements. +- These tests make sure audit entries remain queryable as the system grows. + +## Current Scope + +The suite intentionally focuses on API behaviour and database side effects. It does not deeply test: +- WebSocket connection lifecycle and Redis pub/sub behaviour. +- Real SMTP delivery. +- Browser UI behaviour. +- Every branch of low-level validators or helper functions. + +Those areas are better handled with focused unit tests or E2E tests later. diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..fc9d919 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,150 @@ +import os +from datetime import datetime +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import BigInteger, Integer, create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +os.environ.setdefault("DB_USER", "test_user") +os.environ.setdefault("DB_PASSWORD", "test_password") +os.environ.setdefault("DB_NAME", "test_db") +os.environ.setdefault("SECRET_KEY", "test-secret-key") +os.environ.setdefault("MAIL_HOST", "localhost") +os.environ.setdefault("MAIL_USERNAME", "test") +os.environ.setdefault("MAIL_PASSWORD", "test") +os.environ.setdefault("MAIL_FROM", "noreply@example.test") +os.environ.setdefault("MAIL_FROM_NAME", "PPR Tests") +os.environ.setdefault("BASE_URL", "http://testserver") +os.environ.setdefault("ENVIRONMENT", "test") + +from app.api import deps +from app.db import session as db_session +from app.db.session import Base +from app.main import app +from app.models.ppr import PPRRecord, PPRStatus, UserRole + + +engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def use_sqlite_integer_primary_keys(): + for table in Base.metadata.tables.values(): + for column in table.columns: + if column.primary_key and isinstance(column.type, BigInteger): + column.type = Integer() + + +@pytest.fixture(autouse=True) +def db_session_override(): + Base.metadata.drop_all(bind=engine) + use_sqlite_integer_primary_keys() + Base.metadata.create_all(bind=engine) + + def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + original_session_local = db_session.SessionLocal + db_session.SessionLocal = TestingSessionLocal + app.dependency_overrides[deps.get_db] = override_get_db + + yield + + app.dependency_overrides.clear() + db_session.SessionLocal = original_session_local + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def db(): + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def auth_client(): + test_user = SimpleNamespace( + id=1, + username="test-operator", + role=UserRole.ADMINISTRATOR, + is_active=1, + ) + + app.dependency_overrides[deps.get_current_user] = lambda: test_user + app.dependency_overrides[deps.get_current_active_user] = lambda: test_user + app.dependency_overrides[deps.get_current_read_user] = lambda: test_user + app.dependency_overrides[deps.get_current_operator_user] = lambda: test_user + app.dependency_overrides[deps.get_current_admin_user] = lambda: test_user + + return TestClient(app) + + +@pytest.fixture +def ppr_payload(): + return { + "ac_reg": "g-test", + "ac_type": "C172", + "ac_call": "GTEST", + "captain": "Test Pilot", + "fuel": "AVGAS", + "in_from": "EGLL", + "eta": "2026-06-20T10:00:00", + "pob_in": 2, + "out_to": "EGKK", + "etd": "2026-06-20T12:00:00", + "pob_out": 2, + "email": "pilot@example.com", + "phone": "0123456789", + "notes": "API test flight", + } + + +@pytest.fixture +def ppr_factory(db): + def create_ppr(**overrides): + values = { + "status": PPRStatus.NEW, + "ac_reg": "G-FACT", + "ac_type": "PA28", + "ac_call": "GFACT", + "captain": "Factory Pilot", + "fuel": "AVGAS", + "in_from": "EGLL", + "eta": datetime(2026, 6, 20, 10, 0), + "pob_in": 2, + "out_to": "EGKK", + "etd": datetime(2026, 6, 20, 12, 0), + "pob_out": 2, + "email": None, + "phone": None, + "notes": "Factory test flight", + "created_by": "factory", + "public_token": "token-factory", + } + values.update(overrides) + ppr = PPRRecord(**values) + db.add(ppr) + db.commit() + db.refresh(ppr) + return ppr + + return create_ppr diff --git a/backend/tests/test_app_health.py b/backend/tests/test_app_health.py new file mode 100644 index 0000000..2b01973 --- /dev/null +++ b/backend/tests/test_app_health.py @@ -0,0 +1,14 @@ +def test_root_returns_api_metadata(client): + response = client.get("/") + + assert response.status_code == 200 + assert response.json()["message"] == "Airfield PPR API" + assert response.json()["docs"] == "/docs" + + +def test_health_check_reports_database_connection(client): + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + assert response.json()["database"] == "connected" diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..953cd22 --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -0,0 +1,80 @@ +from app.crud.crud_user import user as crud_user +from app.models.ppr import UserRole +from app.schemas.ppr import UserCreate + + +def test_login_rejects_invalid_credentials(client): + response = client.post( + "/api/v1/auth/login", + data={"username": "missing", "password": "wrong"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Incorrect username or password" + + +def test_login_returns_bearer_token_for_valid_user(client, db): + crud_user.create( + db, + UserCreate(username="tower", password="secret-password", role=UserRole.OPERATOR), + admin_user="test", + ) + + response = client.post( + "/api/v1/auth/login", + data={"username": "tower", "password": "secret-password"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["access_token"] + assert body["token_type"] == "bearer" + assert body["expires_in"] > 0 + + +def test_admin_user_crud_endpoints(auth_client): + create_response = auth_client.post( + "/api/v1/auth/users", + json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"}, + ) + + assert create_response.status_code == 200 + created = create_response.json() + assert created["username"] == "operator-one" + assert created["role"] == "OPERATOR" + + duplicate_response = auth_client.post( + "/api/v1/auth/users", + json={"username": "operator-one", "password": "secret-password", "role": "OPERATOR"}, + ) + + assert duplicate_response.status_code == 400 + + list_response = auth_client.get("/api/v1/auth/users") + assert list_response.status_code == 200 + assert [user["username"] for user in list_response.json()] == ["operator-one"] + + update_response = auth_client.put( + f"/api/v1/auth/users/{created['id']}", + json={"role": "READ_ONLY"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["role"] == "READ_ONLY" + + password_response = auth_client.post( + f"/api/v1/auth/users/{created['id']}/change-password", + json={"password": "new-secret-password"}, + ) + assert password_response.status_code == 200 + + +def test_admin_user_endpoints_return_not_found(auth_client): + assert auth_client.get("/api/v1/auth/users/404").status_code == 404 + assert auth_client.put("/api/v1/auth/users/404", json={"role": "OPERATOR"}).status_code == 404 + assert ( + auth_client.post( + "/api/v1/auth/users/404/change-password", + json={"password": "new-secret-password"}, + ).status_code + == 404 + ) diff --git a/backend/tests/test_circuits_api.py b/backend/tests/test_circuits_api.py new file mode 100644 index 0000000..ac270bf --- /dev/null +++ b/backend/tests/test_circuits_api.py @@ -0,0 +1,97 @@ +from datetime import datetime + +from app.models.arrival import Arrival +from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType +from app.models.movement import Movement, MovementType + + +def test_circuit_lifecycle_for_local_flight(auth_client, db): + flight = LocalFlight( + registration="G-CIR1", + type="C152", + callsign="GCIR1", + pob=1, + flight_type=LocalFlightType.CIRCUITS, + status=LocalFlightStatus.CIRCUIT, + created_by="test", + ) + db.add(flight) + db.commit() + db.refresh(flight) + + create_response = auth_client.post( + "/api/v1/circuits/", + json={"local_flight_id": flight.id, "circuit_timestamp": "2026-06-20T10:10:00"}, + ) + + assert create_response.status_code == 200 + circuit = create_response.json() + assert circuit["local_flight_id"] == flight.id + + assert auth_client.get(f"/api/v1/circuits/{circuit['id']}").status_code == 200 + assert auth_client.get("/api/v1/circuits/").json()[0]["id"] == circuit["id"] + assert auth_client.get(f"/api/v1/circuits/flight/{flight.id}").json()[0]["id"] == circuit["id"] + + movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one() + assert movement.movement_type == MovementType.TOUCH_AND_GO + assert movement.aircraft_registration == "G-CIR1" + + update_response = auth_client.put( + f"/api/v1/circuits/{circuit['id']}", + json={"circuit_timestamp": "2026-06-20T10:20:00"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["circuit_timestamp"] == "2026-06-20T10:20:00" + + delete_response = auth_client.delete(f"/api/v1/circuits/{circuit['id']}") + assert delete_response.status_code == 200 + assert delete_response.json()["detail"] == "Circuit record deleted" + + +def test_circuit_lifecycle_for_arrival_and_error_paths(auth_client, db): + arrival = Arrival( + registration="G-CIR2", + type="PA28", + callsign="GCIR2", + pob=2, + in_from="EGLL", + status="INBOUND", + eta=datetime(2026, 6, 20, 10, 0), + created_by="test", + ) + db.add(arrival) + db.commit() + db.refresh(arrival) + + create_response = auth_client.post( + "/api/v1/circuits/", + json={"arrival_id": arrival.id, "circuit_timestamp": "2026-06-20T10:10:00"}, + ) + + assert create_response.status_code == 200 + circuit = create_response.json() + assert circuit["arrival_id"] == arrival.id + assert auth_client.get(f"/api/v1/circuits/arrival/{arrival.id}").json()[0]["id"] == circuit["id"] + + movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one() + assert movement.movement_type == MovementType.TOUCH_AND_GO + assert movement.from_location == "EGLL" + + missing_entity = auth_client.post( + "/api/v1/circuits/", + json={"circuit_timestamp": "2026-06-20T10:10:00"}, + ) + both_entities = auth_client.post( + "/api/v1/circuits/", + json={ + "local_flight_id": 1, + "arrival_id": arrival.id, + "circuit_timestamp": "2026-06-20T10:10:00", + }, + ) + + assert missing_entity.status_code == 400 + assert both_entities.status_code == 400 + assert auth_client.get("/api/v1/circuits/404").status_code == 404 + assert auth_client.put("/api/v1/circuits/404", json={"circuit_timestamp": "2026-06-20T10:20:00"}).status_code == 404 + assert auth_client.delete("/api/v1/circuits/404").status_code == 404 diff --git a/backend/tests/test_drone_requests_api.py b/backend/tests/test_drone_requests_api.py new file mode 100644 index 0000000..2800956 --- /dev/null +++ b/backend/tests/test_drone_requests_api.py @@ -0,0 +1,144 @@ +from app.models.drone_request import DroneRequest + + +def drone_payload(**overrides): + payload = { + "operator_name": "Rotor Ops", + "operator_id": "OP-123", + "flyer_name": "Remote Pilot", + "flyer_id": "FLY-456", + "email": "pilot@example.com", + "phone": "0123456789", + "flight_date": "2026-06-20", + "estimated_takeoff_time": "10:00", + "estimated_completion_time": "10:30", + "estimated_takeoff_at": "2026-06-20T10:00:00", + "estimated_completion_at": "2026-06-20T10:30:00", + "maximum_elevation_ft_amsl": 250, + "location_description": "North apron", + "location_latitude": 51.47, + "location_longitude": -0.45, + "location_inside_frz": "yes", + "notes": "Survey flight", + "prototype_overlay": {"radius_nm": 1}, + } + payload.update(overrides) + return payload + + +def test_public_drone_request_create_edit_cancel_and_journal(client, db, monkeypatch): + sent_emails = [] + + async def fake_send_email(**kwargs): + sent_emails.append(kwargs) + return True + + monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email) + + create_response = client.post("/api/v1/drone-requests/public", json=drone_payload()) + + assert create_response.status_code == 200 + created = create_response.json() + assert created["reference_number"].startswith("DRN-") + assert created["status"] == "NEW" + assert created["location_inside_frz"] is True + assert created["created_by"] == "public" + assert len(sent_emails) == 1 + + db_request = db.query(DroneRequest).filter(DroneRequest.id == created["id"]).one() + assert db_request.public_token + + get_response = client.get(f"/api/v1/drone-requests/public/edit/{db_request.public_token}") + patch_response = client.patch( + f"/api/v1/drone-requests/public/edit/{db_request.public_token}", + json={"operator_name": "Updated Rotor Ops", "notes": "Updated notes"}, + ) + cancel_response = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}") + + assert get_response.status_code == 200 + assert patch_response.status_code == 200 + assert patch_response.json()["operator_name"] == "Updated Rotor Ops" + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELED" + assert len(sent_emails) == 2 + + blocked_patch = client.patch( + f"/api/v1/drone-requests/public/edit/{db_request.public_token}", + json={"operator_name": "Too Late"}, + ) + blocked_cancel = client.delete(f"/api/v1/drone-requests/public/cancel/{db_request.public_token}") + + assert blocked_patch.status_code == 400 + assert blocked_cancel.status_code == 400 + assert client.get("/api/v1/drone-requests/public/edit/missing-token").status_code == 404 + assert client.patch("/api/v1/drone-requests/public/edit/missing-token", json={}).status_code == 404 + assert client.delete("/api/v1/drone-requests/public/cancel/missing-token").status_code == 404 + + +def test_authenticated_drone_request_list_update_status_comment_and_journal(auth_client, db, monkeypatch): + sent_emails = [] + + async def fake_send_email(**kwargs): + sent_emails.append(kwargs) + return True + + monkeypatch.setattr("app.api.endpoints.drone_requests.email_service.send_email", fake_send_email) + + create_response = auth_client.post("/api/v1/drone-requests/public", json=drone_payload()) + created = create_response.json() + + list_response = auth_client.get( + "/api/v1/drone-requests/", + params={"status": "NEW", "date_from": "2026-06-20", "date_to": "2026-06-20"}, + ) + get_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}") + update_response = auth_client.patch( + f"/api/v1/drone-requests/{created['id']}", + json={"operator_comments": "Needs tower review", "maximum_elevation_ft_amsl": 200}, + ) + status_response = auth_client.patch( + f"/api/v1/drone-requests/{created['id']}/status", + json={"status": "APPROVED", "comment": "Approved below 200ft"}, + ) + comment_response = auth_client.post( + f"/api/v1/drone-requests/{created['id']}/comments", + json={"comment": "Call tower before launch", "email_applicant": True}, + ) + journal_response = auth_client.get(f"/api/v1/drone-requests/{created['id']}/journal") + + assert list_response.status_code == 200 + assert [request["id"] for request in list_response.json()] == [created["id"]] + assert get_response.status_code == 200 + assert update_response.status_code == 200 + assert update_response.json()["maximum_elevation_ft_amsl"] == 200 + assert status_response.status_code == 200 + assert status_response.json()["status"] == "APPROVED" + assert status_response.json()["operator_comments"] == "Approved below 200ft" + assert comment_response.status_code == 200 + assert comment_response.json()["operator_comments"] == "Call tower before launch" + assert journal_response.status_code == 200 + entries = [entry["entry"] for entry in journal_response.json()] + assert any("Drone request" in entry and "created" in entry for entry in entries) + assert any("Status changed from NEW to APPROVED" in entry for entry in entries) + assert any("Comment added" in entry for entry in entries) + assert len(sent_emails) == 3 + + +def test_drone_request_not_found_and_validation_paths(auth_client, client): + invalid_response = client.post( + "/api/v1/drone-requests/public", + json=drone_payload(operator_name=" ", location_latitude=100, maximum_elevation_ft_amsl=-1), + ) + + assert invalid_response.status_code == 422 + assert auth_client.get("/api/v1/drone-requests/404").status_code == 404 + assert auth_client.patch("/api/v1/drone-requests/404", json={"operator_name": "Missing"}).status_code == 404 + assert auth_client.patch("/api/v1/drone-requests/404/status", json={"status": "APPROVED"}).status_code == 404 + assert ( + auth_client.post( + "/api/v1/drone-requests/404/comments", + json={"comment": "Missing", "email_applicant": False}, + ).status_code + == 404 + ) + assert auth_client.get("/api/v1/drone-requests/404/journal").status_code == 404 diff --git a/backend/tests/test_flight_strip_apis.py b/backend/tests/test_flight_strip_apis.py new file mode 100644 index 0000000..088ce8f --- /dev/null +++ b/backend/tests/test_flight_strip_apis.py @@ -0,0 +1,283 @@ +from datetime import datetime + +from app.models.arrival import Arrival +from app.models.departure import Departure, DepartureStatus +from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType +from app.models.movement import Movement, MovementType +from app.models.overflight import Overflight, OverflightStatus + + +def test_arrival_lifecycle_and_not_found_paths(auth_client, db): + payload = { + "registration": "g-arr", + "type": "DA40", + "callsign": "GARR", + "pob": 2, + "in_from": "egll", + "eta": "2026-06-20T09:30:00", + "notes": "Inbound test", + } + + create_response = auth_client.post("/api/v1/arrivals/", json=payload) + assert create_response.status_code == 200 + created = create_response.json() + assert created["registration"] == "G-ARR" + assert created["status"] == "INBOUND" + + assert auth_client.get(f"/api/v1/arrivals/{created['id']}").status_code == 200 + + list_response = auth_client.get( + "/api/v1/arrivals/", + params={"status": "INBOUND", "date_from": "2026-06-20", "date_to": "2026-06-20"}, + ) + assert list_response.status_code == 200 + assert [arrival["id"] for arrival in list_response.json()] == [created["id"]] + + update_response = auth_client.put( + f"/api/v1/arrivals/{created['id']}", + json={"notes": "Updated inbound", "callsign": "ARRIVE"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["notes"] == "Updated inbound" + + status_response = auth_client.patch( + f"/api/v1/arrivals/{created['id']}/status", + json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"}, + ) + assert status_response.status_code == 200 + assert status_response.json()["landed_dt"] == "2026-06-20T10:00:00" + + movement = db.query(Movement).filter(Movement.entity_type == "ARRIVAL").one() + assert movement.movement_type == MovementType.LANDING + assert movement.aircraft_registration == "G-ARR" + + cancel_response = auth_client.delete(f"/api/v1/arrivals/{created['id']}") + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELLED" + + assert auth_client.get("/api/v1/arrivals/404").status_code == 404 + assert auth_client.put("/api/v1/arrivals/404", json={"notes": "x"}).status_code == 404 + assert auth_client.patch("/api/v1/arrivals/404/status", json={"status": "LANDED"}).status_code == 404 + assert auth_client.delete("/api/v1/arrivals/404").status_code == 404 + + +def test_landing_arrival_promotes_linked_pending_departure(auth_client, db): + arrival = Arrival( + registration="G-LINK", + type="PA28", + callsign="GLINK", + pob=2, + in_from="EGLL", + status="INBOUND", + eta=datetime(2026, 6, 20, 9, 30), + created_by="test", + ) + db.add(arrival) + db.commit() + db.refresh(arrival) + + departure = Departure( + registration="G-LINK", + type="PA28", + callsign="GLINK", + pob=2, + out_to="EGKK", + status=DepartureStatus.PENDING, + arrival_id=arrival.id, + created_by="test", + ) + db.add(departure) + db.commit() + + response = auth_client.patch( + f"/api/v1/arrivals/{arrival.id}/status", + json={"status": "LANDED", "timestamp": "2026-06-20T10:00:00"}, + ) + + db.refresh(departure) + assert response.status_code == 200 + assert departure.status == DepartureStatus.BOOKED_OUT + + +def test_departure_lifecycle_and_not_found_paths(auth_client, db): + payload = { + "registration": "g-dep", + "type": "SR22", + "callsign": "GDEP", + "pob": 2, + "out_to": "egkk", + "etd": "2026-06-20T11:00:00", + "notes": "Outbound test", + } + + create_response = auth_client.post("/api/v1/departures/", json=payload) + assert create_response.status_code == 200 + created = create_response.json() + assert created["registration"] == "G-DEP" + assert created["status"] == "GROUND" + + list_response = auth_client.get( + "/api/v1/departures/", + params={"status": "GROUND", "date_from": "2026-06-20", "date_to": "2026-06-20"}, + ) + assert list_response.status_code == 200 + assert [departure["id"] for departure in list_response.json()] == [created["id"]] + + update_response = auth_client.put( + f"/api/v1/departures/{created['id']}", + json={"notes": "Updated outbound", "callsign": "DEPART"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["notes"] == "Updated outbound" + + status_response = auth_client.patch( + f"/api/v1/departures/{created['id']}/status", + json={"status": "LOCAL", "timestamp": "2026-06-20T11:10:00"}, + ) + assert status_response.status_code == 200 + assert status_response.json()["takeoff_dt"] == "2026-06-20T11:10:00" + + movement = db.query(Movement).filter(Movement.entity_type == "DEPARTURE").one() + assert movement.movement_type == MovementType.TAKEOFF + assert movement.to_location == "egkk" + + cancel_response = auth_client.delete(f"/api/v1/departures/{created['id']}") + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELLED" + + assert auth_client.get("/api/v1/departures/404").status_code == 404 + assert auth_client.put("/api/v1/departures/404", json={"notes": "x"}).status_code == 404 + assert auth_client.patch("/api/v1/departures/404/status", json={"status": "DEPARTED"}).status_code == 404 + assert auth_client.delete("/api/v1/departures/404").status_code == 404 + + +def test_local_flight_lifecycle_special_lists_and_not_found_paths(auth_client, db): + payload = { + "registration": "g-loc", + "type": "C152", + "callsign": "GLOC", + "pob": 1, + "flight_type": "LOCAL", + "duration": 45, + "etd": "2026-06-20T10:00:00", + "notes": "Local test", + } + + create_response = auth_client.post("/api/v1/local-flights/", json=payload) + assert create_response.status_code == 200 + created = create_response.json() + assert created["registration"] == "G-LOC" + assert created["status"] == "GROUND" + + filter_response = auth_client.get( + "/api/v1/local-flights/", + params={"status": "GROUND", "flight_type": "LOCAL", "date_from": "2026-06-20"}, + ) + assert filter_response.status_code == 200 + assert [flight["id"] for flight in filter_response.json()] == [created["id"]] + + update_response = auth_client.put( + f"/api/v1/local-flights/{created['id']}", + json={"notes": "Updated local", "duration": 60}, + ) + assert update_response.status_code == 200 + assert update_response.json()["duration"] == 60 + + departed_response = auth_client.patch( + f"/api/v1/local-flights/{created['id']}/status", + json={"status": "DEPARTED", "timestamp": "2026-06-20T10:05:00"}, + ) + landed_response = auth_client.patch( + f"/api/v1/local-flights/{created['id']}/status", + json={"status": "LANDED", "timestamp": "2026-06-20T10:45:00"}, + ) + + assert departed_response.status_code == 200 + assert departed_response.json()["takeoff_dt"] == "2026-06-20T10:05:00" + assert landed_response.status_code == 200 + assert landed_response.json()["landed_dt"] == "2026-06-20T10:45:00" + + movement_types = { + movement.movement_type + for movement in db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all() + } + assert movement_types == {MovementType.TAKEOFF, MovementType.LANDING} + + active_response = auth_client.get("/api/v1/local-flights/active/current") + today_departures_response = auth_client.get("/api/v1/local-flights/today/departures") + booked_out_response = auth_client.get("/api/v1/local-flights/today/booked-out") + + assert active_response.status_code == 200 + assert today_departures_response.status_code == 200 + assert booked_out_response.status_code == 200 + assert booked_out_response.json()[0]["id"] == created["id"] + + cancel_response = auth_client.delete(f"/api/v1/local-flights/{created['id']}") + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELLED" + + assert auth_client.get("/api/v1/local-flights/404").status_code == 404 + assert auth_client.put("/api/v1/local-flights/404", json={"notes": "x"}).status_code == 404 + assert auth_client.patch("/api/v1/local-flights/404/status", json={"status": "LANDED"}).status_code == 404 + assert auth_client.delete("/api/v1/local-flights/404").status_code == 404 + + +def test_overflight_lifecycle_special_lists_and_not_found_paths(auth_client, db): + payload = { + "registration": "g-ovr", + "pob": 1, + "type": "PA28", + "departure_airfield": "egll", + "destination_airfield": "egkk", + "call_dt": "2026-06-20T09:00:00", + "notes": "Overflight test", + } + + create_response = auth_client.post("/api/v1/overflights/", json=payload) + assert create_response.status_code == 200 + created = create_response.json() + assert created["registration"] == "G-OVR" + assert created["departure_airfield"] == "EGLL" + assert created["status"] == "ACTIVE" + + movement = db.query(Movement).filter(Movement.entity_type == "OVERFLIGHT").one() + assert movement.movement_type == MovementType.OVERFLIGHT + assert movement.from_location == "EGLL" + assert movement.to_location == "EGKK" + + active_response = auth_client.get("/api/v1/overflights/active/list") + today_response = auth_client.get("/api/v1/overflights/today/list") + assert active_response.status_code == 200 + assert active_response.json()[0]["id"] == created["id"] + assert today_response.status_code == 200 + assert today_response.json()[0]["id"] == created["id"] + + list_response = auth_client.get( + "/api/v1/overflights/", + params={"status": "ACTIVE", "date_from": "2026-06-20", "date_to": "2026-06-20"}, + ) + assert list_response.status_code == 200 + assert [overflight["id"] for overflight in list_response.json()] == [created["id"]] + + update_response = auth_client.put( + f"/api/v1/overflights/{created['id']}", + json={"notes": "Updated overflight", "destination_airfield": "egcc"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["destination_airfield"] == "EGCC" + + status_response = auth_client.patch( + f"/api/v1/overflights/{created['id']}/status", + json={"status": "INACTIVE", "qsy_dt": "2026-06-20T09:20:00"}, + ) + assert status_response.status_code == 200 + assert status_response.json()["qsy_dt"] == "2026-06-20T09:20:00" + + cancel_response = auth_client.delete(f"/api/v1/overflights/{created['id']}") + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELLED" + + assert auth_client.get("/api/v1/overflights/404").status_code == 404 + assert auth_client.put("/api/v1/overflights/404", json={"notes": "x"}).status_code == 404 + assert auth_client.patch("/api/v1/overflights/404/status", json={"status": "INACTIVE"}).status_code == 404 + assert auth_client.delete("/api/v1/overflights/404").status_code == 404 diff --git a/backend/tests/test_journal_api.py b/backend/tests/test_journal_api.py new file mode 100644 index 0000000..0630eb6 --- /dev/null +++ b/backend/tests/test_journal_api.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta + +from app.models.journal import EntityType +from app.crud.crud_journal import journal + + +def test_search_journal_filters_entries(auth_client, db): + yesterday = datetime.utcnow() - timedelta(days=1) + matching = journal.log_change( + db, + EntityType.PPR, + 10, + "Matching PPR change", + "tower", + "127.0.0.1", + ) + other = journal.log_change( + db, + EntityType.USER, + 20, + "Other user change", + "admin", + "127.0.0.1", + ) + matching.entry_dt = yesterday + other.entry_dt = datetime.utcnow() + db.commit() + + response = auth_client.get( + "/api/v1/journal/search/all", + params={ + "date_from": yesterday.date().isoformat(), + "date_to": yesterday.date().isoformat(), + "entity_type": "PPR", + "entity_id": 10, + "user": "tower", + }, + ) + + assert response.status_code == 200 + entries = response.json() + assert len(entries) == 1 + assert entries[0]["entry"] == "Matching PPR change" + + +def test_search_journal_rejects_invalid_entity_type(auth_client): + response = auth_client.get( + "/api/v1/journal/search/all", + params={"entity_type": "NOT_A_THING"}, + ) + + assert response.status_code == 400 + assert "Invalid entity_type" in response.json()["detail"] + + +def test_get_user_and_entity_journal(auth_client, db): + journal.log_change(db, EntityType.PPR, 55, "PPR audit entry", "tower", None) + + user_response = auth_client.get("/api/v1/journal/user/tower") + entity_response = auth_client.get("/api/v1/journal/PPR/55") + + assert user_response.status_code == 200 + assert user_response.json()[0]["entry"] == "PPR audit entry" + assert entity_response.status_code == 200 + body = entity_response.json() + assert body["entity_type"] == "PPR" + assert body["entity_id"] == 55 + assert body["total_entries"] == 1 + assert body["entries"][0]["entry"] == "PPR audit entry" + + +def test_get_entity_journal_rejects_invalid_entity_type(auth_client): + response = auth_client.get("/api/v1/journal/NOPE/1") + + assert response.status_code == 400 + assert "Invalid entity_type" in response.json()["detail"] diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py deleted file mode 100644 index 725f7fb..0000000 --- a/backend/tests/test_main.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from app.main import app -from app.api.deps import get_db -from app.db.session import Base - -# Create test database -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -Base.metadata.create_all(bind=engine) - -def override_get_db(): - try: - db = TestingSessionLocal() - yield db - finally: - db.close() - -app.dependency_overrides[get_db] = override_get_db - -client = TestClient(app) - -def test_read_main(): - response = client.get("/") - assert response.status_code == 200 - assert "Airfield PPR API" in response.json()["message"] - -def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json()["status"] == "healthy" - -def test_get_public_arrivals(): - response = client.get("/api/v1/public/arrivals") - assert response.status_code == 200 - assert isinstance(response.json(), list) \ No newline at end of file diff --git a/backend/tests/test_movements_api.py b/backend/tests/test_movements_api.py new file mode 100644 index 0000000..f2069b3 --- /dev/null +++ b/backend/tests/test_movements_api.py @@ -0,0 +1,227 @@ +from datetime import datetime + +from app.models.arrival import Arrival +from app.models.departure import Departure +from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType +from app.models.movement import Movement, MovementType +from app.models.overflight import Overflight, OverflightStatus +from app.models.ppr import PPRRecord, PPRStatus + + +def movement_payload(**overrides): + payload = { + "flight_kind": "ARRIVAL", + "movement_date": "2026-06-20", + "movement_time": "10:00", + "aircraft_registration": "G-MOV1", + "aircraft_type": "PA28", + "callsign": "GMOV1", + "from_location": "EGLL", + "to_location": "EGKK", + "pob": 2, + "runway": "27", + "wind": "270/10", + "pressure_setting": "QNH1013", + "notes": "Bulk movement", + } + payload.update(overrides) + return payload + + +def test_movement_list_get_and_context_for_ppr(auth_client, db): + ppr = PPRRecord( + status=PPRStatus.NEW, + ac_reg="G-MOV1", + ac_type="PA28", + ac_call="GMOV1", + captain="Movement Pilot", + in_from="EGLL", + eta=datetime(2026, 6, 20, 10, 0), + pob_in=2, + out_to="EGKK", + etd=datetime(2026, 6, 20, 11, 0), + created_by="test", + public_token="movement-ppr", + ) + db.add(ppr) + db.commit() + db.refresh(ppr) + + bulk_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(ppr_id=ppr.id, landing_time="10:05"), + ) + + assert bulk_response.status_code == 200 + result = bulk_response.json() + assert result["action"] == "created" + assert result["entity_type"] == "PPR" + assert result["entity_id"] == ppr.id + + list_response = auth_client.get( + "/api/v1/movements/", + params={ + "movement_type": "LANDING", + "aircraft_registration": "MOV1", + "date_from": "2026-06-20", + "date_to": "2026-06-20", + "entity_type": "PPR", + }, + ) + get_response = auth_client.get(f"/api/v1/movements/{result['movement']['id']}") + context_response = auth_client.get( + "/api/v1/movements/bulk-context", + params={ + "target_date": "2026-06-20", + "aircraft_registration": "G-MOV1", + "flight_kind": "ARRIVAL", + }, + ) + + assert list_response.status_code == 200 + assert [movement["id"] for movement in list_response.json()] == [result["movement"]["id"]] + assert get_response.status_code == 200 + assert get_response.json()["aircraft_registration"] == "G-MOV1" + assert context_response.status_code == 200 + context = context_response.json() + assert context["pprs"][0]["id"] == ppr.id + assert context["movements"][0]["id"] == result["movement"]["id"] + assert context["suggested"]["source"] == "movement" + + +def test_bulk_log_updates_existing_movement_and_creates_unmatched_arrival_departure(auth_client, db): + arrival_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(aircraft_registration="G-NEW1", landing_time="10:00", from_location="EGBB"), + ) + update_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + aircraft_registration="G-NEW1", + landing_time="10:15", + from_location="EGBB", + notes="Updated movement", + ), + ) + departure_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + flight_kind="DEPARTURE", + aircraft_registration="G-NEW2", + takeoff_time="11:00", + to_location="EGCC", + ), + ) + + assert arrival_response.status_code == 200 + assert arrival_response.json()["entity_type"] == "ARRIVAL" + assert update_response.status_code == 200 + assert update_response.json()["action"] == "updated" + assert update_response.json()["movement"]["timestamp"] == "2026-06-20T10:15:00" + assert departure_response.status_code == 200 + assert departure_response.json()["entity_type"] == "DEPARTURE" + + arrival = db.query(Arrival).filter(Arrival.registration == "G-NEW1").one() + departure = db.query(Departure).filter(Departure.registration == "G-NEW2").one() + assert arrival.status.value == "LANDED" + assert departure.status.value == "DEPARTED" + + +def test_bulk_log_local_and_overflight_branches(auth_client, db): + local_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + flight_kind="LOCAL", + aircraft_registration="G-LOCX", + takeoff_time="09:00", + landing_time="09:45", + local_nature="CIRCUITS", + circuits=3, + ), + ) + overflight_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + flight_kind="OVERFLIGHT", + aircraft_registration="G-OVRX", + contact_time="12:00", + qsy_time="12:15", + from_location="EGLL", + to_location="EGKK", + ), + ) + overflight_update_response = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + flight_kind="OVERFLIGHT", + aircraft_registration="G-OVRX", + contact_time="12:05", + qsy_time="12:20", + from_location="EGLL", + to_location="EGCC", + ), + ) + + assert local_response.status_code == 200 + assert local_response.json()["entity_type"] == "LOCAL_FLIGHT" + local = db.query(LocalFlight).filter(LocalFlight.registration == "G-LOCX").one() + assert local.status == LocalFlightStatus.LANDED + assert local.flight_type == LocalFlightType.CIRCUITS + assert local.circuits == 3 + + local_movements = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").all() + assert {movement.movement_type for movement in local_movements} == { + MovementType.TAKEOFF, + MovementType.LANDING, + } + + assert overflight_response.status_code == 200 + assert overflight_response.json()["entity_type"] == "OVERFLIGHT" + assert overflight_update_response.status_code == 200 + assert overflight_update_response.json()["action"] == "updated" + overflight = db.query(Overflight).filter(Overflight.registration == "G-OVRX").one() + assert overflight.status == OverflightStatus.INACTIVE + assert overflight.destination_airfield == "EGCC" + + +def test_movement_error_paths(auth_client): + missing_registration = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(aircraft_registration=""), + ) + invalid_kind = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(flight_kind="BALLOON"), + ) + missing_time = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(movement_time=None, landing_time=None), + ) + invalid_time = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload(landing_time="not-time"), + ) + bad_local_times = auth_client.post( + "/api/v1/movements/bulk-log", + json=movement_payload( + flight_kind="LOCAL", + takeoff_time="11:00", + landing_time="10:00", + ), + ) + bad_context = auth_client.get( + "/api/v1/movements/bulk-context", + params={ + "target_date": "2026-06-20", + "aircraft_registration": "G-BAD", + "flight_kind": "BALLOON", + }, + ) + + assert missing_registration.status_code == 400 + assert invalid_kind.status_code == 400 + assert missing_time.status_code == 400 + assert invalid_time.status_code == 400 + assert bad_local_times.status_code == 400 + assert bad_context.status_code == 400 + assert auth_client.get("/api/v1/movements/404").status_code == 404 diff --git a/backend/tests/test_pprs_api.py b/backend/tests/test_pprs_api.py new file mode 100644 index 0000000..23c5e44 --- /dev/null +++ b/backend/tests/test_pprs_api.py @@ -0,0 +1,187 @@ +from datetime import datetime + +from app.models.arrival import Arrival +from app.models.departure import Departure, DepartureStatus +from app.models.ppr import PPRRecord + + +def test_ppr_routes_require_authentication(client): + response = client.get("/api/v1/pprs/") + + assert response.status_code in (401, 403) + + +def test_authenticated_user_can_create_read_update_and_audit_ppr(auth_client, ppr_payload): + create_response = auth_client.post("/api/v1/pprs/", json=ppr_payload) + + assert create_response.status_code == 200 + created = create_response.json() + assert created["id"] > 0 + assert created["status"] == "NEW" + assert created["ac_reg"] == "G-TEST" + assert created["created_by"] == "test-operator" + + read_response = auth_client.get(f"/api/v1/pprs/{created['id']}") + + assert read_response.status_code == 200 + assert read_response.json()["ac_reg"] == "G-TEST" + + status_response = auth_client.patch( + f"/api/v1/pprs/{created['id']}/status", + json={"status": "LANDED", "timestamp": "2026-06-20T10:30:00"}, + ) + + assert status_response.status_code == 200 + assert status_response.json()["status"] == "LANDED" + assert status_response.json()["landed_dt"] == "2026-06-20T10:30:00" + + journal_response = auth_client.get(f"/api/v1/pprs/{created['id']}/journal") + + assert journal_response.status_code == 200 + entries = [entry["entry"] for entry in journal_response.json()] + assert any("PPR created for G-TEST" in entry for entry in entries) + assert any("Status changed from NEW to LANDED" in entry for entry in entries) + + +def test_ppr_list_supports_status_date_and_pagination_filters(auth_client, ppr_factory): + ppr_factory( + ac_reg="G-NEW1", + status="NEW", + eta=datetime(2026, 6, 20, 10, 0), + etd=datetime(2026, 6, 20, 12, 0), + public_token="token-new", + ) + ppr_factory( + ac_reg="G-CAN1", + status="CANCELED", + eta=datetime(2026, 6, 21, 10, 0), + etd=datetime(2026, 6, 21, 12, 0), + public_token="token-canceled", + ) + + status_response = auth_client.get("/api/v1/pprs/", params={"status": "NEW"}) + date_response = auth_client.get( + "/api/v1/pprs/", + params={"date_from": "2026-06-21", "date_to": "2026-06-21"}, + ) + limited_response = auth_client.get("/api/v1/pprs/", params={"skip": 1, "limit": 1}) + + assert status_response.status_code == 200 + assert [ppr["ac_reg"] for ppr in status_response.json()] == ["G-NEW1"] + assert date_response.status_code == 200 + assert [ppr["ac_reg"] for ppr in date_response.json()] == ["G-CAN1"] + assert limited_response.status_code == 200 + assert len(limited_response.json()) == 1 + + +def test_authenticated_user_can_put_patch_acknowledge_and_delete_ppr(auth_client, ppr_payload): + created = auth_client.post("/api/v1/pprs/", json=ppr_payload).json() + + put_response = auth_client.put( + f"/api/v1/pprs/{created['id']}", + json={**ppr_payload, "captain": "Updated Pilot"}, + ) + patch_response = auth_client.patch( + f"/api/v1/pprs/{created['id']}", + json={"notes": "Updated by patch"}, + ) + acknowledge_response = auth_client.post(f"/api/v1/pprs/{created['id']}/acknowledge") + delete_response = auth_client.delete(f"/api/v1/pprs/{created['id']}") + + assert put_response.status_code == 200 + assert put_response.json()["captain"] == "Updated Pilot" + assert patch_response.status_code == 200 + assert patch_response.json()["notes"] == "Updated by patch" + assert acknowledge_response.status_code == 200 + assert acknowledge_response.json()["acknowledged_by"] == "test-operator" + assert delete_response.status_code == 200 + assert delete_response.json()["status"] == "DELETED" + + +def test_ppr_not_found_paths(auth_client): + assert auth_client.get("/api/v1/pprs/404").status_code == 404 + assert auth_client.put("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404 + assert auth_client.patch("/api/v1/pprs/404", json={"captain": "Nobody"}).status_code == 404 + assert auth_client.patch("/api/v1/pprs/404/status", json={"status": "LANDED"}).status_code == 404 + assert auth_client.post("/api/v1/pprs/404/acknowledge").status_code == 404 + assert auth_client.delete("/api/v1/pprs/404").status_code == 404 + assert auth_client.get("/api/v1/pprs/404/journal").status_code == 404 + assert auth_client.post("/api/v1/pprs/404/activate").status_code == 404 + + +def test_public_ppr_create_sends_email_and_generates_token(client, db, ppr_payload, monkeypatch): + sent_email = {} + + async def fake_send_email(**kwargs): + sent_email.update(kwargs) + return True + + monkeypatch.setattr("app.api.endpoints.pprs.email_service.send_email", fake_send_email) + + create_response = client.post("/api/v1/pprs/public", json=ppr_payload) + + assert create_response.status_code == 200 + created = create_response.json() + assert created["created_by"] == "public" + assert sent_email["to_email"] == "pilot@example.com" + + db_ppr = db.query(PPRRecord).filter(PPRRecord.id == created["id"]).one() + assert db_ppr.public_token + + +def test_public_ppr_token_edit_and_cancel_paths(client, ppr_factory, db): + ppr = ppr_factory(public_token="public-edit-token", email=None) + + get_response = client.get("/api/v1/pprs/public/edit/public-edit-token") + patch_response = client.patch( + "/api/v1/pprs/public/edit/public-edit-token", + json={"captain": "Public Editor"}, + ) + cancel_response = client.delete("/api/v1/pprs/public/cancel/public-edit-token") + + assert get_response.status_code == 200 + assert get_response.json()["id"] == ppr.id + assert patch_response.status_code == 200 + assert patch_response.json()["captain"] == "Public Editor" + assert cancel_response.status_code == 200 + assert cancel_response.json()["status"] == "CANCELED" + + assert client.get("/api/v1/pprs/public/edit/public-edit-token").status_code == 400 + assert client.patch("/api/v1/pprs/public/edit/missing-token", json={}).status_code == 404 + assert client.delete("/api/v1/pprs/public/cancel/missing-token").status_code == 404 + + +def test_activate_ppr_creates_arrival_and_pending_departure(auth_client, ppr_factory, db): + ppr = ppr_factory(public_token="activate-token") + + response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate") + + assert response.status_code == 200 + body = response.json() + assert body["arrival_id"] + assert body["departure_id"] + + arrival = db.query(Arrival).filter(Arrival.id == body["arrival_id"]).one() + departure = db.query(Departure).filter(Departure.id == body["departure_id"]).one() + + assert arrival.registration == "G-FACT" + assert arrival.in_from == "EGLL" + assert departure.status == DepartureStatus.PENDING + assert departure.arrival_id == arrival.id + + +def test_activate_rejects_processed_ppr(auth_client, ppr_factory): + ppr = ppr_factory(status="LANDED", public_token="processed-token") + + response = auth_client.post(f"/api/v1/pprs/{ppr.id}/activate") + + assert response.status_code == 400 + assert "cannot be activated" in response.json()["detail"] + + +def test_invalid_ppr_payload_returns_validation_error(auth_client, ppr_payload): + ppr_payload["pob_in"] = -1 + + response = auth_client.post("/api/v1/pprs/", json=ppr_payload) + + assert response.status_code == 422 diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py new file mode 100644 index 0000000..f9b768d --- /dev/null +++ b/backend/tests/test_public_api.py @@ -0,0 +1,176 @@ +from datetime import datetime, timedelta + +from app.models.arrival import Arrival, ArrivalStatus +from app.models.departure import Departure, DepartureStatus +from app.models.local_flight import LocalFlight, LocalFlightStatus, LocalFlightType +from app.models.ppr import Aircraft, Airport, PPRRecord, PPRStatus + + +def test_public_arrivals_and_departures_start_empty(client): + arrivals = client.get("/api/v1/public/arrivals") + departures = client.get("/api/v1/public/departures") + + assert arrivals.status_code == 200 + assert arrivals.json() == [] + assert departures.status_code == 200 + assert departures.json() == [] + + +def test_public_boards_include_todays_flights(client, db): + now = datetime.now().replace(microsecond=0) + ppr = PPRRecord( + status=PPRStatus.LANDED, + ac_reg="G-PPR1", + ac_type="PA28", + ac_call="GPPR1", + captain="Arriving Pilot", + in_from="EGLL", + eta=now, + pob_in=2, + out_to="EGKK", + etd=now, + pob_out=2, + created_by="test", + ) + local_flight = LocalFlight( + registration="G-LOC1", + type="C152", + callsign="GLOC1", + pob=1, + flight_type=LocalFlightType.LOCAL, + status=LocalFlightStatus.BOOKED_OUT, + created_dt=now, + etd=now, + ) + arrival = Arrival( + registration="G-ARR1", + type="DA40", + callsign="GARR1", + pob=3, + in_from="EGBB", + status=ArrivalStatus.INBOUND, + created_dt=now, + eta=now, + ) + departure = Departure( + registration="G-DEP1", + type="SR22", + callsign="GDEP1", + pob=2, + out_to="EGCC", + status=DepartureStatus.BOOKED_OUT, + created_dt=now, + etd=now, + ) + db.add_all([ppr, local_flight, arrival, departure]) + db.commit() + + arrivals = client.get("/api/v1/public/arrivals") + departures = client.get("/api/v1/public/departures") + + assert arrivals.status_code == 200 + assert {item.get("ac_reg") or item.get("registration") for item in arrivals.json()} == { + "G-PPR1", + "G-ARR1", + } + assert departures.status_code == 200 + assert {item["ac_reg"] for item in departures.json()} == { + "G-PPR1", + "G-LOC1", + "G-DEP1", + } + + +def test_public_reference_lookups_return_seeded_records(client, db): + db.add( + Airport( + icao="EGLL", + iata="LHR", + name="London Heathrow", + country="United Kingdom", + city="London", + ) + ) + db.add( + Aircraft( + registration="G-ABCD", + type_code="PA28", + clean_reg="GABCD", + manufacturer_name="Piper", + model="Cherokee", + ) + ) + db.commit() + + airport_response = client.get("/api/v1/airport/public/lookup/EGLL") + aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-ABC") + + assert airport_response.status_code == 200 + assert airport_response.json()[0]["icao"] == "EGLL" + assert aircraft_response.status_code == 200 + assert aircraft_response.json()[0]["registration"] == "G-ABCD" + + +def test_public_reference_lookups_return_empty_for_short_queries(client): + airport_response = client.get("/api/v1/airport/public/lookup/E") + aircraft_response = client.get("/api/v1/aircraft/public/lookup/G-A") + + assert airport_response.status_code == 200 + assert airport_response.json() == [] + assert aircraft_response.status_code == 200 + assert aircraft_response.json() == [] + + +def test_public_airport_lookup_searches_by_partial_name(client, db): + db.add( + Airport( + icao="EGBB", + iata="BHX", + name="Birmingham Airport", + country="United Kingdom", + city="Birmingham", + ) + ) + db.commit() + + response = client.get("/api/v1/airport/public/lookup/Birmingham") + + assert response.status_code == 200 + assert response.json()[0]["icao"] == "EGBB" + + +def test_public_boards_exclude_old_and_cancelled_records(client, db): + yesterday = datetime.now().replace(microsecond=0) - timedelta(days=1) + db.add( + PPRRecord( + status=PPRStatus.CANCELED, + ac_reg="G-OLD1", + ac_type="PA28", + captain="Old Pilot", + in_from="EGLL", + eta=yesterday, + pob_in=1, + out_to="EGKK", + etd=yesterday, + created_by="test", + ) + ) + db.add( + LocalFlight( + registration="G-OLD2", + type="C152", + pob=1, + flight_type=LocalFlightType.LOCAL, + status=LocalFlightStatus.BOOKED_OUT, + created_dt=yesterday, + ) + ) + db.commit() + + arrivals = client.get("/api/v1/public/arrivals") + departures = client.get("/api/v1/public/departures") + + assert arrivals.status_code == 200 + assert arrivals.json() == [] + assert departures.status_code == 200 + assert departures.json() == [] diff --git a/backend/tests/test_public_book_api.py b/backend/tests/test_public_book_api.py new file mode 100644 index 0000000..4e7eda4 --- /dev/null +++ b/backend/tests/test_public_book_api.py @@ -0,0 +1,138 @@ +from app.models.arrival import Arrival +from app.models.departure import Departure +from app.models.local_flight import LocalFlight +from app.models.movement import Movement, MovementType + + +def enable_public_booking(monkeypatch, enabled=True): + monkeypatch.setattr("app.api.endpoints.public_book.settings.allow_public_booking", enabled) + + +def test_public_booking_rejects_requests_when_disabled(client, monkeypatch): + enable_public_booking(monkeypatch, enabled=False) + + response = client.post( + "/api/v1/public-book/local-flights", + json={ + "registration": "G-PUB1", + "pob": 1, + "flight_type": "LOCAL", + }, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Public booking is currently disabled" + + +def test_public_booking_creates_local_flight_and_circuit(client, db, monkeypatch): + enable_public_booking(monkeypatch) + + flight_response = client.post( + "/api/v1/public-book/local-flights", + json={ + "registration": "g-pub1", + "type": "C152", + "callsign": "GPUB1", + "pob": 1, + "flight_type": "LOCAL", + "duration": 30, + "etd": "2026-06-20T10:00:00", + "notes": "Public local", + "pilot_email": " PILOT@EXAMPLE.COM ", + }, + ) + + assert flight_response.status_code == 200 + flight = flight_response.json() + assert flight["registration"] == "G-PUB1" + assert flight["status"] == "BOOKED_OUT" + assert flight["submitted_via"] == "PUBLIC" + assert flight["pilot_email"] == "pilot@example.com" + + circuit_response = client.post( + "/api/v1/public-book/circuits", + json={ + "local_flight_id": flight["id"], + "circuit_timestamp": "2026-06-20T10:15:00", + "pilot_email": "", + }, + ) + + assert circuit_response.status_code == 200 + assert circuit_response.json()["local_flight_id"] == flight["id"] + + db_flight = db.query(LocalFlight).filter(LocalFlight.id == flight["id"]).one() + assert db_flight.created_by == "PUBLIC_PILOT" + movement = db.query(Movement).filter(Movement.entity_type == "LOCAL_FLIGHT").one() + assert movement.movement_type == MovementType.TOUCH_AND_GO + + +def test_public_booking_creates_departure_and_arrival(client, db, monkeypatch): + enable_public_booking(monkeypatch) + + departure_response = client.post( + "/api/v1/public-book/departures", + json={ + "registration": "g-pub2", + "type": "PA28", + "callsign": "GPUB2", + "pob": 2, + "out_to": "egkk", + "etd": "2026-06-20T11:00:00", + "notes": "Public departure", + "pilot_email": "depart@example.com", + }, + ) + arrival_response = client.post( + "/api/v1/public-book/arrivals", + json={ + "registration": "g-pub3", + "type": "DA40", + "callsign": "GPUB3", + "pob": 3, + "in_from": "egll", + "eta": "2026-06-20T12:00:00", + "notes": "Public arrival", + "pilot_email": "arrive@example.com", + }, + ) + + assert departure_response.status_code == 200 + departure = departure_response.json() + assert departure["registration"] == "G-PUB2" + assert departure["status"] == "BOOKED_OUT" + assert departure["submitted_via"] == "PUBLIC" + assert departure["pilot_email"] == "depart@example.com" + + assert arrival_response.status_code == 200 + arrival = arrival_response.json() + assert arrival["registration"] == "G-PUB3" + assert arrival["status"] == "BOOKED_IN" + assert arrival["submitted_via"] == "PUBLIC" + assert arrival["pilot_email"] == "arrive@example.com" + + db_departure = db.query(Departure).filter(Departure.id == departure["id"]).one() + db_arrival = db.query(Arrival).filter(Arrival.id == arrival["id"]).one() + assert db_departure.created_by == "PUBLIC_PILOT" + assert db_arrival.created_by == "PUBLIC_PILOT" + + +def test_public_booking_validates_payloads(client, monkeypatch): + enable_public_booking(monkeypatch) + + local_response = client.post( + "/api/v1/public-book/local-flights", + json={"registration": "", "pob": 0, "flight_type": "LOCAL"}, + ) + departure_response = client.post( + "/api/v1/public-book/departures", + json={"registration": "G-BAD", "pob": 0, "out_to": ""}, + ) + arrival_response = client.post( + "/api/v1/public-book/arrivals", + json={"registration": "G-BAD", "pob": 0, "in_from": ""}, + ) + + assert local_response.status_code == 422 + assert departure_response.status_code == 422 + assert arrival_response.status_code == 422