Add unit tests for station service and enhance documentation
Some checks failed
Backend CI / lint-and-test (push) Failing after 37s
Some checks failed
Backend CI / lint-and-test (push) Failing after 37s
- Introduced unit tests for the station service, covering creation, updating, and archiving of stations. - Added detailed building block view documentation outlining the architecture of the Rail Game system. - Created runtime view documentation illustrating key user interactions and system behavior. - Developed concepts documentation detailing domain models, architectural patterns, and security considerations. - Updated architecture documentation to reference new detailed sections for building block and runtime views.
This commit is contained in:
28
backend/tests/test_osm_config.py
Normal file
28
backend/tests/test_osm_config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from backend.app.core.osm_config import (
|
||||
DEFAULT_REGIONS,
|
||||
STATION_TAG_FILTERS,
|
||||
BoundingBox,
|
||||
compile_overpass_filters,
|
||||
)
|
||||
|
||||
|
||||
def test_default_regions_are_valid() -> None:
|
||||
assert DEFAULT_REGIONS, "Expected at least one region definition"
|
||||
for bbox in DEFAULT_REGIONS:
|
||||
assert isinstance(bbox, BoundingBox)
|
||||
assert bbox.north > bbox.south
|
||||
assert bbox.east > bbox.west
|
||||
# Berlin coordinates should fall inside Berlin bounding box for sanity
|
||||
if bbox.name == "berlin_metropolitan":
|
||||
assert bbox.contains(52.5200, 13.4050)
|
||||
|
||||
|
||||
def test_station_tag_filters_compile_to_overpass_snippet() -> None:
|
||||
compiled = compile_overpass_filters(STATION_TAG_FILTERS)
|
||||
# Ensure each key is present with its values
|
||||
for key, values in STATION_TAG_FILTERS.items():
|
||||
assert key in compiled
|
||||
for value in values:
|
||||
assert value in compiled
|
||||
# The snippet should be multi-line to preserve readability
|
||||
assert "\n" in compiled
|
||||
137
backend/tests/test_stations_api.py
Normal file
137
backend/tests/test_stations_api.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.api import stations as stations_api
|
||||
from backend.app.main import app
|
||||
from backend.app.models import StationCreate, StationModel, StationUpdate
|
||||
|
||||
AUTH_CREDENTIALS = {"username": "demo", "password": "railgame123"}
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _authenticate() -> str:
|
||||
response = client.post("/api/auth/login", json=AUTH_CREDENTIALS)
|
||||
assert response.status_code == 200
|
||||
return response.json()["accessToken"]
|
||||
|
||||
|
||||
def _station_payload(**overrides: Any) -> dict[str, Any]:
|
||||
payload = {
|
||||
"name": "Central",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"osmId": "123",
|
||||
"code": "BER",
|
||||
"elevationM": 34.5,
|
||||
"isActive": True,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _station_model(**overrides: Any) -> StationModel:
|
||||
now = datetime.now(timezone.utc)
|
||||
base = StationModel(
|
||||
id=str(uuid4()),
|
||||
name="Central",
|
||||
latitude=52.52,
|
||||
longitude=13.405,
|
||||
code="BER",
|
||||
osm_id="123",
|
||||
elevation_m=34.5,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return base.model_copy(update=overrides)
|
||||
|
||||
|
||||
def test_list_stations_requires_authentication() -> None:
|
||||
response = client.get("/api/stations")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_stations_returns_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
token = _authenticate()
|
||||
|
||||
def fake_list_stations(db, include_inactive: bool) -> list[StationModel]:
|
||||
assert include_inactive is True
|
||||
return [_station_model()]
|
||||
|
||||
monkeypatch.setattr(stations_api, "list_stations", fake_list_stations)
|
||||
|
||||
response = client.get(
|
||||
"/api/stations",
|
||||
params={"include_inactive": "true"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["name"] == "Central"
|
||||
|
||||
|
||||
def test_create_station_delegates_to_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
token = _authenticate()
|
||||
seen: dict[str, StationCreate] = {}
|
||||
|
||||
def fake_create_station(db, payload: StationCreate) -> StationModel:
|
||||
seen["payload"] = payload
|
||||
return _station_model()
|
||||
|
||||
monkeypatch.setattr(stations_api, "create_station", fake_create_station)
|
||||
|
||||
response = client.post(
|
||||
"/api/stations",
|
||||
json=_station_payload(),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["name"] == "Central"
|
||||
assert seen["payload"].name == "Central"
|
||||
|
||||
|
||||
def test_update_station_not_found_returns_404(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
token = _authenticate()
|
||||
|
||||
def fake_update_station(
|
||||
db, station_id: str, payload: StationUpdate
|
||||
) -> StationModel:
|
||||
raise LookupError("Station not found")
|
||||
|
||||
monkeypatch.setattr(stations_api, "update_station", fake_update_station)
|
||||
|
||||
response = client.put(
|
||||
"/api/stations/123e4567-e89b-12d3-a456-426614174000",
|
||||
json={"name": "New Name"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Station not found"
|
||||
|
||||
|
||||
def test_archive_station_returns_updated_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
token = _authenticate()
|
||||
|
||||
def fake_archive_station(db, station_id: str) -> StationModel:
|
||||
return _station_model(is_active=False)
|
||||
|
||||
monkeypatch.setattr(stations_api, "archive_station", fake_archive_station)
|
||||
|
||||
response = client.post(
|
||||
"/api/stations/123e4567-e89b-12d3-a456-426614174000/archive",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["isActive"] is False
|
||||
67
backend/tests/test_stations_import.py
Normal file
67
backend/tests/test_stations_import.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from backend.scripts.stations_import import (
|
||||
build_overpass_query,
|
||||
normalize_station_elements,
|
||||
)
|
||||
|
||||
|
||||
def test_build_overpass_query_single_region() -> None:
|
||||
query = build_overpass_query("berlin_metropolitan")
|
||||
|
||||
# The query should reference the Berlin bounding box coordinates.
|
||||
assert "52.3381" in query # south
|
||||
assert "52.6755" in query # north
|
||||
assert "13.0884" in query # west
|
||||
assert "13.7611" in query # east
|
||||
assert "node" in query
|
||||
assert "out body" in query
|
||||
|
||||
|
||||
def test_build_overpass_query_all_regions_includes_union() -> None:
|
||||
query = build_overpass_query("all")
|
||||
|
||||
# Ensure multiple regions are present by checking for repeated bbox parentheses.
|
||||
assert query.count("node") >= 3
|
||||
assert query.strip().endswith("out skel qt;")
|
||||
|
||||
|
||||
def test_normalize_station_elements_filters_and_transforms() -> None:
|
||||
raw_elements = [
|
||||
{
|
||||
"type": "node",
|
||||
"id": 123,
|
||||
"lat": 52.5,
|
||||
"lon": 13.4,
|
||||
"tags": {
|
||||
"name": "Sample Station",
|
||||
"ref": "XYZ",
|
||||
"ele": "35.5",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"id": 999,
|
||||
# Missing coordinates should be ignored
|
||||
"tags": {"name": "Broken"},
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"id": 456,
|
||||
"lat": 50.0,
|
||||
"lon": 8.0,
|
||||
"tags": {
|
||||
"name": "Disused Station",
|
||||
"disused": "yes",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
stations = normalize_station_elements(raw_elements)
|
||||
|
||||
assert len(stations) == 2
|
||||
primary = stations[0]
|
||||
assert primary["osm_id"] == "123"
|
||||
assert primary["name"] == "Sample Station"
|
||||
assert primary["code"] == "XYZ"
|
||||
assert primary["elevation_m"] == 35.5
|
||||
disused_station = stations[1]
|
||||
assert disused_station["is_active"] is False
|
||||
142
backend/tests/test_stations_load.py
Normal file
142
backend/tests/test_stations_load.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.scripts import stations_load
|
||||
|
||||
|
||||
def test_parse_station_entries_returns_models() -> None:
|
||||
entries = [
|
||||
{
|
||||
"name": "Central",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"osm_id": "123",
|
||||
"code": "BER",
|
||||
"elevation_m": 34.5,
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
|
||||
parsed = stations_load._parse_station_entries(entries)
|
||||
|
||||
assert parsed[0].name == "Central"
|
||||
assert parsed[0].latitude == 52.52
|
||||
assert parsed[0].osm_id == "123"
|
||||
|
||||
|
||||
def test_parse_station_entries_invalid_raises_value_error() -> None:
|
||||
entries = [
|
||||
{
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
stations_load._parse_station_entries(entries)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummySession:
|
||||
committed: bool = False
|
||||
rolled_back: bool = False
|
||||
closed: bool = False
|
||||
|
||||
def __enter__(self) -> "DummySession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
self.closed = True
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyRepository:
|
||||
session: DummySession
|
||||
created: list = field(default_factory=list)
|
||||
|
||||
def create(self, data) -> None: # pragma: no cover - simple delegation
|
||||
self.created.append(data)
|
||||
|
||||
|
||||
class DummySessionFactory:
|
||||
def __call__(self) -> DummySession:
|
||||
return DummySession()
|
||||
|
||||
|
||||
def test_load_stations_commits_when_requested(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repo_instances: list[DummyRepository] = []
|
||||
|
||||
def fake_session_local() -> DummySession:
|
||||
return DummySession()
|
||||
|
||||
def fake_repo(session: DummySession) -> DummyRepository:
|
||||
repo = DummyRepository(session)
|
||||
repo_instances.append(repo)
|
||||
return repo
|
||||
|
||||
monkeypatch.setattr(stations_load, "SessionLocal", fake_session_local)
|
||||
monkeypatch.setattr(stations_load, "StationRepository", fake_repo)
|
||||
|
||||
stations = stations_load._parse_station_entries(
|
||||
[
|
||||
{
|
||||
"name": "Central",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"osm_id": "123",
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
created = stations_load.load_stations(stations, commit=True)
|
||||
|
||||
assert created == 1
|
||||
assert repo_instances[0].session.committed is True
|
||||
assert repo_instances[0].session.rolled_back is False
|
||||
assert len(repo_instances[0].created) == 1
|
||||
|
||||
|
||||
def test_load_stations_rolls_back_when_no_commit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repo_instances: list[DummyRepository] = []
|
||||
|
||||
def fake_session_local() -> DummySession:
|
||||
return DummySession()
|
||||
|
||||
def fake_repo(session: DummySession) -> DummyRepository:
|
||||
repo = DummyRepository(session)
|
||||
repo_instances.append(repo)
|
||||
return repo
|
||||
|
||||
monkeypatch.setattr(stations_load, "SessionLocal", fake_session_local)
|
||||
monkeypatch.setattr(stations_load, "StationRepository", fake_repo)
|
||||
|
||||
stations = stations_load._parse_station_entries(
|
||||
[
|
||||
{
|
||||
"name": "Central",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"osm_id": "123",
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
created = stations_load.load_stations(stations, commit=False)
|
||||
|
||||
assert created == 1
|
||||
assert repo_instances[0].session.committed is False
|
||||
assert repo_instances[0].session.rolled_back is True
|
||||
175
backend/tests/test_stations_service.py
Normal file
175
backend/tests/test_stations_service.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from geoalchemy2.elements import WKTElement
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.models import StationCreate, StationUpdate
|
||||
from backend.app.services import stations as stations_service
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummySession:
|
||||
flushed: bool = False
|
||||
committed: bool = False
|
||||
refreshed: List[object] = field(default_factory=list)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
def refresh(self, instance: object) -> None: # pragma: no cover - simple setter
|
||||
self.refreshed.append(instance)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyStation:
|
||||
id: UUID
|
||||
name: str
|
||||
location: WKTElement
|
||||
osm_id: str | None
|
||||
code: str | None
|
||||
elevation_m: float | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DummyStationRepository:
|
||||
_store: Dict[UUID, DummyStation] = {}
|
||||
|
||||
def __init__(self, session: DummySession) -> None: # pragma: no cover - simple init
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _point(latitude: float, longitude: float) -> WKTElement:
|
||||
return WKTElement(f"POINT({longitude} {latitude})", srid=4326)
|
||||
|
||||
def list(self) -> list[DummyStation]:
|
||||
return list(self._store.values())
|
||||
|
||||
def list_active(self) -> list[DummyStation]:
|
||||
return [station for station in self._store.values() if station.is_active]
|
||||
|
||||
def get(self, identifier: UUID) -> DummyStation | None:
|
||||
return self._store.get(identifier)
|
||||
|
||||
def create(self, payload: StationCreate) -> DummyStation:
|
||||
station = DummyStation(
|
||||
id=uuid4(),
|
||||
name=payload.name,
|
||||
location=self._point(payload.latitude, payload.longitude),
|
||||
osm_id=payload.osm_id,
|
||||
code=payload.code,
|
||||
elevation_m=payload.elevation_m,
|
||||
is_active=payload.is_active,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._store[station.id] = station
|
||||
return station
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_store(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
DummyStationRepository._store = {}
|
||||
monkeypatch.setattr(stations_service, "StationRepository", DummyStationRepository)
|
||||
|
||||
|
||||
def test_create_station_persists_and_returns_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = DummySession()
|
||||
payload = StationCreate(
|
||||
name="Central",
|
||||
latitude=52.52,
|
||||
longitude=13.405,
|
||||
osm_id="123",
|
||||
code="BER",
|
||||
elevation_m=34.5,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
result = stations_service.create_station(cast(Session, session), payload)
|
||||
|
||||
assert session.flushed is True
|
||||
assert session.committed is True
|
||||
assert result.name == "Central"
|
||||
assert result.latitude == pytest.approx(52.52)
|
||||
assert result.longitude == pytest.approx(13.405)
|
||||
assert result.osm_id == "123"
|
||||
|
||||
|
||||
def test_update_station_updates_geometry_and_metadata() -> None:
|
||||
session = DummySession()
|
||||
station_id = uuid4()
|
||||
DummyStationRepository._store[station_id] = DummyStation(
|
||||
id=station_id,
|
||||
name="Old Name",
|
||||
location=DummyStationRepository._point(50.0, 8.0),
|
||||
osm_id=None,
|
||||
code=None,
|
||||
elevation_m=None,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
payload = StationUpdate(name="New Name", latitude=51.0, longitude=9.0)
|
||||
result = stations_service.update_station(
|
||||
cast(Session, session), str(station_id), payload
|
||||
)
|
||||
|
||||
assert result.name == "New Name"
|
||||
assert result.latitude == pytest.approx(51.0)
|
||||
assert result.longitude == pytest.approx(9.0)
|
||||
assert DummyStationRepository._store[station_id].name == "New Name"
|
||||
|
||||
|
||||
def test_update_station_requires_both_coordinates() -> None:
|
||||
session = DummySession()
|
||||
station_id = uuid4()
|
||||
DummyStationRepository._store[station_id] = DummyStation(
|
||||
id=station_id,
|
||||
name="Station",
|
||||
location=DummyStationRepository._point(50.0, 8.0),
|
||||
osm_id=None,
|
||||
code=None,
|
||||
elevation_m=None,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
stations_service.update_station(
|
||||
cast(Session, session), str(station_id), StationUpdate(latitude=51.0)
|
||||
)
|
||||
|
||||
|
||||
def test_archive_station_marks_inactive() -> None:
|
||||
session = DummySession()
|
||||
station_id = uuid4()
|
||||
DummyStationRepository._store[station_id] = DummyStation(
|
||||
id=station_id,
|
||||
name="Station",
|
||||
location=DummyStationRepository._point(50.0, 8.0),
|
||||
osm_id=None,
|
||||
code=None,
|
||||
elevation_m=None,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
result = stations_service.archive_station(cast(Session, session), str(station_id))
|
||||
|
||||
assert result.is_active is False
|
||||
assert DummyStationRepository._store[station_id].is_active is False
|
||||
Reference in New Issue
Block a user