"""Offline tests for public transport relation search and its JSON endpoint.""" import importlib.util import json import sys from pathlib import Path from typing import Any from urllib.parse import parse_qs import pytest import requests import responses from osm_geojson.pt import core def relation(relation_id: int = 1, **tags: str) -> dict[str, Any]: """Build a minimal tagged Overpass relation.""" return {"type": "relation", "id": relation_id, "tags": {"type": "route", "route": "bus", **tags}} @pytest.fixture() def app(monkeypatch: Any) -> Any: """Load the web application independently of Python's import path.""" spec = importlib.util.spec_from_file_location("pt_web_app", Path(__file__).parents[1] / "web/app.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) monkeypatch.setitem(sys.modules, spec.name, module) spec.loader.exec_module(module) module.app.config["TESTING"] = True return module.app @responses.activate def test_search_metadata_and_query() -> None: """Return routes and masters, exclude other relations, and prefer exact refs.""" responses.post(core.OVERPASS_URL, json={"elements": [ relation(2, name="Airport shuttle", ref="X1", operator="City Bus", **{"from": "Airport", "to": "Centre"}), relation(3, name="M11", type="route_master", route_master="subway", ref="M11"), relation(1, name="Metro M11", route="subway", ref="M11"), relation(4, route="hiking"), {"type": "node", "id": 5}, ]}) result = core.search_routes(" M11 ") assert [item["id"] for item in result["routes"]] == [3, 1, 2] assert result["routes"][0]["type"] == "route_master" assert result["routes"][2]["operator"] == "City Bus" assert result["truncated"] is False request = responses.calls[0].request query = parse_qs(request.body)["data"][0] assert '["ref"~"^M11$",i]' in query assert '["route_master"~' in query assert 'out tags 51;' in query assert request.headers["User-Agent"] == "osm-pt-geojson/1.0" @responses.activate def test_literal_search_and_mode_filter() -> None: """User text stays a literal regex and cannot inject Overpass selectors.""" responses.post(core.OVERPASS_URL, json={"elements": []}) text = 'İstanbul [A.*] "\\);out;' assert core.search_routes(text, "subway")["routes"] == [] query = parse_qs(responses.calls[0].request.body)["data"][0] escaped = 'İstanbul \\[A\\.\\*\\] "\\\\\\);out;' assert f'["name"~{json.dumps(escaped, ensure_ascii=False)},i]' in query assert '["route"~"^(subway)$"]' in query assert '["route_master"~"^(subway)$"]' in query @pytest.mark.parametrize("query,mode", [("", ""), ("x", ""), ("x" * 101, ""), ("a\nb", ""), ("M11", "hiking")]) @responses.activate def test_invalid_search(query: str, mode: str) -> None: """Reject invalid input without contacting Overpass.""" with pytest.raises(core.OsmError) as error: core.search_routes(query, mode) assert error.value.status_code == 400 assert len(responses.calls) == 0 @responses.activate def test_search_limit() -> None: """Tell the UI when more than fifty relations matched.""" responses.post(core.OVERPASS_URL, json={"elements": [relation(i) for i in range(51)]}) result = core.search_routes("Airport") assert len(result["routes"]) == 50 assert result["truncated"] is True @pytest.mark.parametrize("payload", [{"remark": "runtime error: Query timed out", "elements": []}, {}, []]) @responses.activate def test_incomplete_search(payload: Any) -> None: """A timed-out or malformed response must not look like an empty result.""" responses.post(core.OVERPASS_URL, json=payload) with pytest.raises(core.OsmError) as error: core.search_routes("M11") assert error.value.status_code == 502 @pytest.mark.parametrize("failure", [requests.Timeout("timeout"), "invalid JSON", 429, 504]) @responses.activate def test_upstream_search_failure(failure: Any) -> None: """Network, rate-limit and decoding failures become library errors.""" if isinstance(failure, int): responses.post(core.OVERPASS_URL, status=failure) else: responses.post(core.OVERPASS_URL, body=failure) with pytest.raises(core.OsmError) as error: core.search_routes("M11") assert error.value.status_code == 502 @responses.activate def test_search_api(app: Any) -> None: """The endpoint handles results, empty matches, invalid inputs and upstream errors.""" responses.post(core.OVERPASS_URL, json={"elements": [relation(15083963, name="M11", ref="M11")]}) client = app.test_client() result = client.get("/api/search?q=M11&mode=subway") assert result.status_code == 200 assert result.json["routes"][0]["id"] == 15083963 responses.post(core.OVERPASS_URL, json={"elements": []}) assert client.get("/api/search?q=Unknown").json == {"routes": [], "truncated": False} for path in ("/api/search", "/api/search?q=M11&mode=hiking"): result = client.get(path) assert result.status_code == 400 assert result.json["error"] == "invalid_query" responses.post(core.OVERPASS_URL, status=503) result = client.get("/api/search?q=M11") assert result.status_code == 502 assert result.json["error"] == "osm_error" @pytest.mark.parametrize("width", [1280, 390]) def test_search_browser(app: Any, width: int, monkeypatch: Any) -> None: """Search results load routes on desktop and mobile, with safe text and errors.""" import threading from werkzeug.serving import make_server browser_api = pytest.importorskip("playwright.sync_api") monkeypatch.setitem(app.view_functions["route_page"].__globals__, "fetch_relation_name", lambda _: "Metro M11") server = make_server("127.0.0.1", 0, app) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: with browser_api.sync_playwright() as playwright: browser = playwright.chromium.launch() page = browser.new_page(viewport={"width": width, "height": 844}) errors: list[str] = [] location_requests: list[str] = [] page.on("pageerror", lambda error: errors.append(str(error))) page.on('request', lambda request: location_requests.append(request.url) if '/api/locations?' in request.url else None) page.route("**/tile.openstreetmap.org/**", lambda route: route.abort()) page.route("**/api/locations?*", lambda route: route.fulfill(json={"locations": [ {"label": "Bristol, UK", "bounds": [51.39, -2.73, 51.55, -2.51]}, {"label": "Bristol, USA", "bounds": [41.6, -72.9, 41.8, -72.8]}, ]})) page.route("**/api/search?*", lambda route: route.fulfill(json={ "routes": [{"id": 15083963, "name": "Metro M11