from copy import deepcopy from datetime import UTC, datetime, timedelta from xml.etree import ElementTree import pytest from app import create_app from app.extensions import db from app.models import PlannedRoute, User from app.planner_engine import PlannerError, analyse, generate, search_locations, validate @pytest.fixture def app(tmp_path): app = create_app( { "TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite://", "SECRET_KEY": "planner-test", "WTF_CSRF_ENABLED": False, "RATELIMIT_ENABLED": False, "LOCAL_SETUP": False, "ORS_API_KEY": "test-key", "DATA_ROOT": str(tmp_path), } ) with app.app_context(): db.create_all() for name in ("runner", "other"): user = User(username=name, email=f"{name}@example.test", display_name=name) user.set_password("a-long-test-password") db.session.add(user) db.session.commit() yield app def login(client, name="runner"): client.post( "/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"} ) def preferences(): return {"start": [151, -33], "mode": "loop", "distance": 5, "hill": "rolling"} def feature(): return { "geometry": { "type": "LineString", "coordinates": [[151, -33, 10], [151.001, -33, 30], [151, -33, 10]], }, "properties": { "summary": {"distance": 5100}, "ascent": 20, "extras": { "waytype": { "summary": [{"value": 7, "distance": 4080}, {"value": 3, "distance": 1020}] }, "surface": { "summary": [{"value": 3, "distance": 4590}, {"value": 0, "distance": 510}] }, }, }, } @pytest.mark.parametrize( "change", [ {"distance": float("nan")}, {"distance": 51}, {"start": [151, 91]}, {"start": [True, -33]}, {"hill": "invalid"}, {"mode": "point"}, {"surface": "invalid"}, {"distance": False}, {"mode": []}, {"avoid_repeats": "false"}, ], ) def test_validation(change): with pytest.raises(PlannerError): validate(preferences() | change) def test_point_distance_rejected(): with pytest.raises(PlannerError, match="straight-line"): validate(preferences() | {"mode": "point", "end": [152, -33]}) def test_quality_and_missing_data(): plan = analyse(feature(), validate(preferences())) assert plan["quality"] == { "paths_pct": 80, "roads_pct": 20, "paved_pct": 90, "unknown_surface_pct": 10, "repeats_pct": 50, } assert plan["within_target"] assert len(plan["profile"]) == 3 assert plan["profile"][-1][0] == 5100 missing = feature() missing["properties"].pop("extras") missing["properties"].pop("ascent") for coordinate in missing["geometry"]["coordinates"]: coordinate.pop() plan = analyse(missing, validate(preferences())) assert plan["quality"]["paths_pct"] is None assert plan["quality"]["unknown_surface_pct"] is None assert plan["ascent_m"] is None assert plan["profile"] == [] def test_provider_float_category_ids(): item = feature() for extra in item["properties"]["extras"].values(): for row in extra["summary"]: row["value"] = float(row["value"]) plan = analyse(item, validate(preferences())) assert plan["quality"]["paths_pct"] == 80 assert plan["quality"]["roads_pct"] == 20 assert plan["quality"]["paved_pct"] == 90 assert plan["quality"]["unknown_surface_pct"] == 10 def test_ranking_and_provider_contract(monkeypatch): calls = [] def fake(body, key): calls.append(deepcopy(body)) item = feature() item["properties"]["summary"]["distance"] = [7000, 5050, 5300][len(calls) - 1] item["geometry"]["coordinates"][1][0] += len(calls) / 100 return {"features": [item]} monkeypatch.setattr("app.planner_engine.provider_request", fake) routes, errors = generate(validate(preferences() | {"priority": "distance"}), "test") assert len(routes) == 3 and not errors assert routes[0]["distance_m"] == 5050 assert [x["options"]["round_trip"]["seed"] for x in calls] == [1, 2, 3] assert all(x["elevation"] and x["extra_info"] == ["surface", "waytype"] for x in calls) def test_point_requests_and_duplicates(monkeypatch): calls = [] def fake(body, key): calls.append(body) return {"features": [feature()]} monkeypatch.setattr("app.planner_engine.provider_request", fake) routes, _ = generate(validate(preferences() | {"mode": "point", "end": [151.01, -33]}), "test") assert len(routes) == 1 assert [len(body["coordinates"]) for body in calls] == [2, 3, 3] assert all("options" not in body for body in calls) def test_generate_save_export_delete_and_ownership(app, monkeypatch): monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]}) client = app.test_client() assert client.get("/routes").status_code == 302 login(client) assert client.get("/routes").status_code == 200 response = client.post("/routes/generate", json=preferences()) assert response.status_code == 200 route_id = response.json["routes"][0]["id"] assert client.get(f"/routes/{route_id}/gpx").status_code == 404 assert ( client.post(f"/routes/{route_id}/save", json={"title": "Morning & hills"}).status_code == 200 ) exported = client.get(f"/routes/{route_id}/gpx") assert exported.status_code == 200 document = ElementTree.fromstring(exported.data) ns = {"g": "http://www.topografix.com/GPX/1/1"} assert document.find("g:trk/g:name", ns).text == "Morning & hills" assert len(document.findall(".//g:trkpt", ns)) == 3 assert document.find(".//g:trkpt", ns).attrib == {"lat": "-33.0", "lon": "151.0"} other = app.test_client() login(other, "other") for suffix in ("", "/gpx"): assert other.get(f"/routes/{route_id}{suffix}").status_code == 404 for suffix in ("save", "delete"): assert ( other.post(f"/routes/{route_id}/{suffix}", json={"title": "stolen"}).status_code == 404 ) assert b"Morning & hills" not in other.get("/routes").data assert client.post(f"/routes/{route_id}/delete", json={}).status_code == 200 assert client.get(f"/routes/{route_id}").status_code == 404 def test_configuration_failure_and_csrf(app): app.config["ORS_API_KEY"] = None client = app.test_client() login(client) assert b"not connected yet" in client.get("/routes").data response = client.post("/routes/generate", json=preferences()) assert response.status_code == 400 and "ORS_API_KEY" in response.json["error"] app.config["WTF_CSRF_ENABLED"] = True assert client.post("/routes/generate", json=preferences()).status_code == 400 def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch): monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]}) with app.app_context(): for saved in (False, True): db.session.add( PlannedRoute( owner_id=1, title="old", saved=saved, created_at=datetime.now(UTC) - timedelta(days=2), payload={}, ) ) db.session.commit() client = app.test_client() login(client) assert client.post("/routes/generate", json=preferences()).status_code == 200 with app.app_context(): assert db.session.get(PlannedRoute, 1) is None assert db.session.get(PlannedRoute, 2).saved def test_location_search_contract_and_invalid_results(monkeypatch): import json from urllib.parse import parse_qs, urlsplit calls = [] class Reply: def __enter__(self): return self def __exit__(self, *_): pass def read(self, limit): assert limit == 1_000_001 return json.dumps( { "features": [ { "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}, "properties": {"label": "Park & gardens, Sydney"}, }, { "geometry": {"type": "Point", "coordinates": [0, 99]}, "properties": {"label": "Invalid"}, }, {"geometry": {"type": "LineString", "coordinates": []}, "properties": {}}, ] } ).encode() def fake(req, timeout): calls.append(req) assert timeout == 8 return Reply() monkeypatch.setattr("app.planner_engine.urlopen", fake) locations = search_locations( {"text": "Park & gardens", "focus": [151, -33]}, "private-test-key" ) assert locations == [{"label": "Park & gardens, Sydney", "coordinates": [151.2, -33.9]}] query = parse_qs(urlsplit(calls[0].full_url).query) assert query["text"] == ["Park & gardens"] assert query["size"] == ["5"] and query["focus.point.lon"] == ["151.0"] assert "private-test-key" not in calls[0].full_url assert calls[0].get_header("Authorization") == "private-test-key" @pytest.mark.parametrize( "data", [None, {}, {"text": "ab"}, {"text": "x" * 201}, {"text": "Sydney", "focus": [0, 99]}] ) def test_location_search_validation(data): with pytest.raises(PlannerError): search_locations(data, "test") def test_location_search_login_configuration_and_csrf(app, monkeypatch): monkeypatch.setattr( "app.planner.search_locations", lambda data, key: [{"label": "Public park", "coordinates": [151, -33]}], ) client = app.test_client() assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 302 login(client) response = client.post("/routes/search", json={"text": "Sydney"}) assert response.status_code == 200 and response.json["locations"][0]["label"] == "Public park" app.config["WTF_CSRF_ENABLED"] = True assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 400 with pytest.raises(PlannerError, match="not connected"): search_locations({"text": "Sydney"}, None) def test_location_search_failure_is_actionable(monkeypatch): from urllib.error import URLError def fail(*_, **__): raise URLError("private provider internals") monkeypatch.setattr("app.planner_engine.urlopen", fail) with pytest.raises(PlannerError, match="selecting a point on the map") as error: search_locations({"text": "Sydney"}, "private-key") assert "private" not in str(error.value)