Flash out API test suite
This commit is contained in:
@@ -181,9 +181,32 @@ docker exec -it ppr_nextgen_db mysql -u ppr_user -p ppr_nextgen
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### 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
|
```bash
|
||||||
cd backend
|
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
|
## Additional Features
|
||||||
@@ -293,4 +316,4 @@ docker-compose down
|
|||||||
To remove volumes (database data):
|
To remove volumes (database data):
|
||||||
```bash
|
```bash
|
||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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_journal import journal as crud_journal
|
||||||
from app.crud.crud_arrival import arrival as crud_arrival
|
from app.crud.crud_arrival import arrival as crud_arrival
|
||||||
from app.crud.crud_departure import departure as crud_departure
|
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.arrival import ArrivalCreate
|
||||||
from app.schemas.departure import DepartureCreate
|
from app.schemas.departure import DepartureCreate
|
||||||
from app.models.ppr import User
|
from app.models.ppr import User
|
||||||
@@ -397,7 +398,7 @@ async def cancel_ppr_public(
|
|||||||
return cancelled_ppr
|
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(
|
async def get_ppr_journal(
|
||||||
ppr_id: int,
|
ppr_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -412,7 +413,7 @@ async def get_ppr_journal(
|
|||||||
detail="PPR record not found"
|
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")
|
@router.post("/{ppr_id}/activate")
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ class CRUDPPR:
|
|||||||
# Log the deletion in journal
|
# Log the deletion in journal
|
||||||
crud_journal.log_change(
|
crud_journal.log_change(
|
||||||
db,
|
db,
|
||||||
|
EntityType.PPR,
|
||||||
db_obj.id,
|
db_obj.id,
|
||||||
f"PPR marked as DELETED (was {old_status.value})",
|
f"PPR marked as DELETED (was {old_status.value})",
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, DateTime, Enum as SQLEnum, func
|
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 enum import Enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from app.db.session import Base
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class SubmissionSource(str, Enum):
|
class SubmissionSource(str, Enum):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,8 +12,9 @@ email-validator==2.1.0
|
|||||||
pydantic[email]==2.5.0
|
pydantic[email]==2.5.0
|
||||||
pydantic-settings==2.0.3
|
pydantic-settings==2.0.3
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
|
pytest-cov==4.1.0
|
||||||
pytest-asyncio==0.21.1
|
pytest-asyncio==0.21.1
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
aiosmtplib==3.0.1
|
aiosmtplib==3.0.1
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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() == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user