Flash out API test suite

This commit is contained in:
2026-06-20 04:01:24 -04:00
parent 78d738b0ee
commit fc394b8555
19 changed files with 1818 additions and 51 deletions
+25 -2
View File
@@ -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
```
```
+4 -3
View File
@@ -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")
+1
View File
@@ -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,
+1 -3
View File
@@ -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):
+14
View File
@@ -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
+2 -1
View File
@@ -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
jinja2==3.1.2
+199
View File
@@ -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.
+150
View File
@@ -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
+14
View File
@@ -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"
+80
View File
@@ -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
)
+97
View File
@@ -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
+144
View File
@@ -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
+283
View File
@@ -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
+76
View File
@@ -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"]
-42
View File
@@ -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)
+227
View File
@@ -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
+187
View File
@@ -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
+176
View File
@@ -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() == []
+138
View File
@@ -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