agenda/tests/test_conference_detail.py

587 lines
23 KiB
Python

"""Conference detail pages, airport resolution and flight caching."""
import typing
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import pytest
import yaml
import agenda.conference_detail as detail
import agenda.conference_flights as flights
import agenda.conference_list
import agenda.conference_page as page
import agenda.flight_search_cache as cache
import agenda.trip
import web_view
from agenda.types import StrDict
def test_airport_suggestions(tmp_path: Path) -> None:
"""Mappings can be overridden; proximity stays within the conference country."""
conf: StrDict = {"location": "Brussels", "country": "be"}
assert detail.suggest_airport(conf, str(tmp_path))[0] == "BRU"
(tmp_path / "conference_airports.yaml").write_text("be:brussels: CRL\n")
assert detail.suggest_airport(conf, str(tmp_path))[0] == "CRL"
(tmp_path / "airports.yaml").write_text(
yaml.safe_dump(
{
"AAA": {
"city": "Elsewhere",
"country": "fr",
"latitude": 50,
"longitude": 4,
},
"BBB": {
"city": "Another",
"country": "be",
"latitude": 50.1,
"longitude": 4.1,
},
}
)
)
assert (
detail.suggest_airport(
{"location": "Town", "country": "be", "latitude": 50, "longitude": 4},
str(tmp_path),
)[0]
== "BBB"
)
assert detail.suggest_airport({"location": "TBC"}, str(tmp_path))[0] is None
def test_short_haul() -> None:
"""European geography determines Bristol preference, with a distance limit."""
assert detail.is_short_haul({"country": "BE"})
assert not detail.is_short_haul({"country": "us"})
assert not detail.is_short_haul({"country": "fr", "latitude": -21, "longitude": 55})
def test_page_is_cache_only(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""GET displays venue/details without initiating a search; POST requires a token."""
conf: StrDict = {
"name": "Mapping 2099",
"location": "Brussels",
"country": "be",
"topic": "Maps",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
"venue": "Test Venue",
"address": "123 Test Street",
"latitude": 50.8,
"longitude": 4.3,
"url": "https://example.com/conference",
"description": "A great conference",
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
monkeypatch.setattr(
flights, "lookup", lambda *args: pytest.fail("GET made a lookup")
)
monkeypatch.setattr(flights, "valid_airport", lambda code: code == "BRU")
url = f"/conference/2099-05-05/{detail.conference_slug(conf)}"
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
for value in (
b"Test Venue",
b"123 Test Street",
b"View venue on map",
b"A great conference",
b"Look up flights",
b"Conference website",
):
assert value in response.data
assert client.post(url, data={"airport": "BRU"}).status_code == 400
assert client.get("/conference/2099-05-05/missing").status_code == 404
cached: StrDict = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"destination": "BRU",
"searches": [],
}
path = flights.cache_path(
str(tmp_path), conf["start"], conf["end"], "BRU", True
)
path.parent.mkdir()
flights.write_cache(path, cached)
response = client.get(url)
assert b"Last updated" in response.data and b"Update flights" in response.data
with client.session_transaction() as session:
token = session["conference_flight_token"]
calls: list[str] = []
def lookup(*args: typing.Any) -> StrDict:
calls.append(args[3])
return cached
monkeypatch.setattr(flights, "lookup", lookup)
assert (
client.post(url, data={"airport": "BRU", "csrf_token": token}).status_code
== 302
)
assert calls == ["BRU"]
monkeypatch.setattr(
flights,
"resolve_airport",
lambda query: ("CPH", "Copenhagen Kastrup Airport"),
)
response = client.post(
url + "?airport=BRU",
data={"airport": "Copenhagen, Denmark", "csrf_token": token},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("airport=CPH")
assert calls == ["BRU", "CPH"]
def test_slug_collision(tmp_path: Path) -> None:
"""Events of one series on one day receive distinct reproducible URLs."""
conferences = [
{
"name": name,
"series": "maps",
"location": "Berlin",
"start": date(2099, 5, 1),
}
for name in ("Maps morning", "Maps evening")
]
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump(conferences))
rows = agenda.conference_list.build_conference_list(str(tmp_path), [])
assert rows[0]["slug"] != rows[1]["slug"]
assert (
detail.conference_slug({"name": "FOSDEM", "location": "Brussels"})
== "fosdem-brussels"
)
@pytest.mark.parametrize(
"short_haul,bristol_complete,expected",
[(True, True, ["BRS"]), (True, False, ["BRS", "LON"]), (False, True, ["LON"])],
)
def test_cache_and_fallback(
tmp_path: Path,
monkeypatch: typing.Any,
short_haul: bool,
bristol_complete: bool,
expected: list[str],
) -> None:
"""Bristol preference, London fallback, and refresh throttling."""
calls: list[str] = []
def search(
origin: str, destination: str, start: date, end: date, flexible: bool
) -> StrDict:
calls.append(origin)
return {
"origin": origin,
"outbound": [{"price": 50}],
"inbound": [{"price": 60}] if bristol_complete else [],
}
monkeypatch.setattr(flights, "search_origin", search)
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "BRU", short_haul)
result = flights.lookup(path, start, end, "BRU", short_haul)
assert calls == expected
assert flights.lookup(path, start, end, "BRU", short_haul) == result
assert calls == expected
def test_failed_refresh_keeps_results(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""Failures retain old results and get a short retry cooldown."""
path = flights.cache_path(
str(tmp_path), date(2099, 1, 5), date(2099, 1, 6), "BRU", True
)
path.parent.mkdir()
old: StrDict = {
"updated_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"destination": "BRU",
"searches": [{"origin": "BRS", "outbound": [1], "inbound": [2]}],
}
flights.write_cache(path, old)
calls: list[bool] = []
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
calls.append(True)
raise RuntimeError("network unavailable")
monkeypatch.setattr(flights, "search_origin", fail)
result = flights.lookup(path, date(2099, 1, 5), date(2099, 1, 6), "BRU", True)
assert result["updated_at"] == old["updated_at"]
assert result["searches"] == old["searches"]
assert result["error"]
assert "RuntimeError: network unavailable" in result["error_details"]
flights.lookup(path, date(2099, 1, 5), date(2099, 1, 6), "BRU", True)
assert len(calls) == 1
@pytest.mark.parametrize(
"outbound_offset,inbound_offset,expected_calls",
[(1, 1, 2), (3, 1, 4), (1, 3, 4), (None, None, 8)],
)
def test_flexible_direct_dates(
monkeypatch: typing.Any,
outbound_offset: int | None,
inbound_offset: int | None,
expected_calls: int,
) -> None:
"""Each direction expands only as needed, excluding late outbound arrivals."""
calls: list[tuple[str, str, date, bool]] = []
start, end = date(2099, 1, 5), date(2099, 1, 6)
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append((origin, destination, day, direct))
if origin == "BRS":
offset = (start - day).days
if offset == 1 and outbound_offset != 1:
return [{"arrival": start.isoformat() + "T18:00:00", "price": 30}]
available = offset == outbound_offset
else:
available = (day - end).days == inbound_offset
return (
[{"arrival": day.isoformat() + "T18:00:00", "price": 30}]
if available
else []
)
monkeypatch.setattr(flights, "search_day", search)
result = flights.search_origin("BRS", "BRU", start, end, True)
assert len(calls) == expected_calls
assert all(call[3] for call in calls)
assert bool(result["outbound"]) == (outbound_offset is not None)
assert bool(result["inbound"]) == (inbound_offset is not None)
assert sum(call[0] == "BRS" for call in calls) == (outbound_offset or 4)
assert sum(call[0] == "BRU" for call in calls) == (inbound_offset or 4)
def test_bad_cache(tmp_path: Path) -> None:
"""Invalid JSON and naive timestamps are ignored."""
path = tmp_path / "cache.json"
path.write_text('{"updated_at": "2026-01-01T12:00:00", "searches": []}')
assert flights.read_cache(path) is None
path.write_text("bad json")
assert flights.read_cache(path) is None
def test_airport_autocomplete(monkeypatch: typing.Any) -> None:
"""Autocomplete uses the local index and returns airport names and codes."""
queries: list[str] = []
def matches(query: str) -> list[StrDict]:
queries.append(query)
return [{"code": "CPH", "name": "Copenhagen Kastrup Airport"}]
monkeypatch.setattr(flights, "airport_matches", matches)
with web_view.app.test_client() as client:
assert client.get("/conference/airports?q=C").json == []
response = client.get("/conference/airports?q=Copenhagen%2C%20Denmark")
assert response.json == [{"code": "CPH", "name": "Copenhagen Kastrup Airport"}]
assert queries == ["Copenhagen, Denmark"]
def test_multi_airport_location_mapping(tmp_path: Path) -> None:
"""Mappings support airport lists, accents, and personal overrides."""
for location in ("Malmö", "Malmo"):
assert (
detail.suggest_airport(
{"country": "se", "location": location}, str(tmp_path)
)[0]
== "MMX,CPH"
)
(tmp_path / "conference_airports.yaml").write_text('"se:malmo": [CPH, MMX]\n')
assert (
detail.suggest_airport({"country": "se", "location": "Malmö"}, str(tmp_path))[0]
== "CPH,MMX"
)
@pytest.mark.parametrize("available_offset", [2, 3, None])
def test_london_earlier_departure(
monkeypatch: typing.Any, available_offset: int | None
) -> None:
"""London expands the outbound by one day, without repeating the return search."""
start, end = date(2099, 1, 5), date(2099, 1, 6)
calls: list[tuple[str, date]] = []
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append((origin, day))
if origin == "LON" and (start - day).days != available_offset:
return []
return [{"arrival": day.isoformat() + "T18:00:00"}]
monkeypatch.setattr(flights, "search_day", search)
result = flights.search_origin("LON", "BKW", start, end, False)
expected = ["2099-01-03"] if available_offset == 2 else ["2099-01-03", "2099-01-02"]
assert result["outbound_dates"] == expected
assert result["inbound_dates"] == ["2099-01-07"]
assert sum(origin == "BKW" for origin, _ in calls) == 1
assert bool(result["outbound"]) == (available_offset is not None)
def test_legacy_empty_search_can_expand(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""An old fresh empty London cache can immediately try the new earlier date."""
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "BKW", False)
path.parent.mkdir()
flights.write_cache(
path,
{
"updated_at": datetime.now(timezone.utc).isoformat(),
"searches": [{"origin": "LON", "outbound": [], "inbound": []}],
"transport": cache.TRANSPORT,
},
)
calls: list[date] = []
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append(day)
return []
monkeypatch.setattr(flights, "search_day", search)
result = flights.lookup(path, start, end, "BKW", False)
assert result["searches"][0]["outbound_dates"] == ["2099-01-03", "2099-01-02"]
assert len(calls) == 3
assert flights.lookup(path, start, end, "BKW", False) == result
assert len(calls) == 3
def test_empty_flights_show_airport_and_dates(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Cached empty results show full airport names and checked departure dates."""
conf: StrDict = {
"name": "Mapping 2099",
"location": "Raleigh",
"country": "us",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
path = flights.cache_path(str(tmp_path), conf["start"], conf["end"], "BKW", False)
path.parent.mkdir()
cached: StrDict = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"destination": "BKW",
"searches": [
{
"origin": "LON",
"outbound": [],
"inbound": [],
"outbound_dates": ["2099-05-03", "2099-05-02"],
"inbound_dates": ["2099-05-07"],
}
],
}
flights.write_cache(path, cached)
url = "/conference/2099-05-05/" + detail.conference_slug(conf) + "?airport=BKW"
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert b"Raleigh County Memorial Airport (BKW)</h3>" in response.data
for day in ("2099-05-03", "2099-05-02", "2099-05-07"):
assert page.format_date(day).encode() in response.data
# Legacy cache data reconstructs the single London departure date.
del cached["searches"][0]["outbound_dates"]
del cached["searches"][0]["inbound_dates"]
flights.write_cache(path, cached)
response = client.get(url)
assert page.format_date("2099-05-03").encode() in response.data
@pytest.mark.parametrize(
"location,online,eligible",
[("Belfast", False, True), ("Belfast", True, False), ("London", False, False)],
)
def test_belfast_domestic_exception(
tmp_path: Path, monkeypatch: typing.Any, location: str, online: bool, eligible: bool
) -> None:
"""Belfast alone permits UK flight lookups; online events remain excluded."""
conf: StrDict = {
"name": "Test conference",
"location": location,
"country": "gb",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
"online": online,
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
calls: list[tuple[str, bool]] = []
def lookup(
path: Path, start: date, end: date, destination: str, short_haul: bool
) -> StrDict:
calls.append((destination, short_haul))
return {}
monkeypatch.setattr(flights, "lookup", lookup)
url = "/conference/2099-05-05/" + detail.conference_slug(conf)
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert (b"Look up flights" in response.data) == eligible
assert calls == []
if eligible:
assert b"BFS,BHD" in response.data
with client.session_transaction() as session:
token = session["conference_flight_token"]
response = client.post(url, data={"airport": location, "csrf_token": token})
assert response.status_code == (302 if eligible else 400)
assert calls == ([("BFS,BHD", True)] if eligible else [])
def test_personal_airport_names(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""Personal names apply to codes, groups and autocomplete, with live updates."""
path = tmp_path / "airports.yaml"
path.write_text(
yaml.safe_dump({"BSL": {"name": "My Basel Airport"}, "BRN": {"name": ""}})
)
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
with web_view.app.app_context():
assert flights.resolve_airport("BSL") == ("BSL", "My Basel Airport")
code, name = flights.resolve_airport("Bern")
assert code == "BRN,BSL"
assert name == "Bern Airport / My Basel Airport"
assert flights.resolve_airport("BRN,BSL")[1] == name
assert flights.airport_matches("My Basel Airport")[0] == {
"code": "BSL",
"name": "My Basel Airport",
}
assert any(
match["code"] == "BSL" for match in flights.airport_matches("EuroAirport")
)
with web_view.app.test_client() as client:
assert client.get("/conference/airports?q=BSL").json == [
{"code": "BSL", "name": "My Basel Airport"}
]
path.write_text(yaml.safe_dump({"BSL": {"name": "Renamed Basel Airport"}}))
assert client.get("/conference/airports?q=BSL").json == [
{"code": "BSL", "name": "Renamed Basel Airport"}
]
path.unlink()
fallback = client.get("/conference/airports?q=BSL").json
assert isinstance(fallback, list)
assert "EuroAirport" in fallback[0]["name"]
def test_date_limit_banner(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""A wrapped date rejection asks to retry nearer departure, keeping cached fares."""
from agenda.google_flights import FlightDateUnavailableError
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "AKJ", False)
path.parent.mkdir()
old: StrDict = {
"updated_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"destination": "AKJ",
"searches": [{"origin": "LON", "outbound": [{"price": 100}], "inbound": []}],
}
flights.write_cache(path, old)
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
try:
raise FlightDateUnavailableError(
"Requested flight date is too far in the future. Please try again closer to departure."
)
except FlightDateUnavailableError as exc:
raise RuntimeError("AKJ → LON on 2099-01-07") from exc
monkeypatch.setattr(flights, "search_origin", fail)
result = flights.lookup(path, start, end, "AKJ", False)
assert "closer to departure" in result["error"]
assert "15 minutes" not in result["error"]
assert result["searches"] == old["searches"]
assert result["updated_at"] == old["updated_at"]
def test_previous_parser_error_can_retry_once(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""The parser fix unlocks a recent old-format error, but fresh errors still throttle."""
start, end = date(2099, 6, 2), date(2099, 6, 4)
path = flights.cache_path(str(tmp_path), start, end, "BLQ", True)
path.parent.mkdir()
flights.write_cache(
path,
{
"updated_at": datetime.now(timezone.utc).isoformat(),
"searches": [],
"error": "Flight lookup failed",
"error_details": "ValueError: Unrecognized Google Flights result format",
"transport": cache.TRANSPORT,
},
)
calls: list[bool] = []
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
calls.append(True)
raise ValueError("Unrecognized Google Flights result format")
monkeypatch.setattr(flights, "search_origin", fail)
refreshed = flights.lookup(path, start, end, "BLQ", True)
assert len(calls) == 1
assert flights.lookup(path, start, end, "BLQ", True) == refreshed
assert len(calls) == 1
@pytest.mark.parametrize(
"status,eligible", [("exact", True), ("tentative", True), ("approximate", False)]
)
def test_flight_lookup_with_tentative_dates(
tmp_path: Path, monkeypatch: typing.Any, status: str, eligible: bool
) -> None:
"""Tentative start/end dates support lookup and links; approximate ranges don't."""
start, end = date(2099, 6, 2), date(2099, 6, 4)
dates: StrDict = {"status": status}
dates.update(
{"earliest": start, "latest": end}
if status == "approximate"
else {"start": start, "end": end}
)
conf: StrDict = {
"name": "PyCon test",
"location": "Bologna",
"country": "it",
"dates": dates,
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
calls: list[tuple[date, date, str]] = []
def lookup(
path: Path, departure: date, returning: date, destination: str, short_haul: bool
) -> StrDict:
calls.append((departure, returning, destination))
return {}
monkeypatch.setattr(flights, "lookup", lookup)
url = "/conference/2099-06-02/" + detail.conference_slug(conf)
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert (b"Look up flights" in response.data) == eligible
assert (b"Open in Google Flights" in response.data) == eligible
assert (b"Using tentative conference dates" in response.data) == (
status == "tentative"
)
if status == "tentative":
assert page.format_date(start).encode() in response.data
assert page.format_date(end).encode() in response.data
assert calls == []
with client.session_transaction() as session:
token = session["conference_flight_token"]
response = client.post(url, data={"airport": "BLQ", "csrf_token": token})
assert response.status_code == (302 if eligible else 400)
assert calls == ([(start, end, "BLQ")] if eligible else [])