Add conference detail pages and cached flight searches
This commit is contained in:
parent
6b253f2d04
commit
dcaf723336
24 changed files with 12198 additions and 12 deletions
587
tests/test_conference_detail.py
Normal file
587
tests/test_conference_detail.py
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
"""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 [])
|
||||
148
tests/test_flight_diagnostics.py
Normal file
148
tests/test_flight_diagnostics.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Offline failure recording, replay and retention coverage."""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import typing
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from agenda import flight_diagnostics, flight_search_cache, google_flights
|
||||
|
||||
|
||||
def read_snapshot(path: Path) -> dict[str, typing.Any]:
|
||||
"""Read the same artifact an administrator can use for analysis."""
|
||||
with gzip.open(path, "rt", encoding="utf-8") as source:
|
||||
return typing.cast(dict[str, typing.Any], json.load(source))
|
||||
|
||||
|
||||
def test_failed_page_saved_for_replay(tmp_path: Path, monkeypatch: typing.Any) -> None:
|
||||
"""A parser failure saves the route, raw script and loaded response content."""
|
||||
script = "AF_initDataCallback({data:[]});"
|
||||
page_text = "Something went wrong"
|
||||
html = "<html><body>Something went wrong</body></html>"
|
||||
url = google_flights.search_url("AKJ", "LON", date(2099, 9, 12), 1)
|
||||
page = SimpleNamespace(
|
||||
url=url,
|
||||
goto=lambda *args, **kwargs: SimpleNamespace(status=200),
|
||||
content=lambda: html,
|
||||
locator=lambda selector: SimpleNamespace(
|
||||
wait_for=lambda **kwargs: None,
|
||||
inner_text=lambda **kwargs: page_text if selector == "body" else script,
|
||||
),
|
||||
)
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
browser.page = typing.cast(Page, page)
|
||||
monkeypatch.setattr(browser, "start", lambda: browser.page)
|
||||
with pytest.raises(ValueError, match="unusable search data"):
|
||||
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
|
||||
path = next((tmp_path / "errors").glob("*.json.gz"))
|
||||
snapshot = read_snapshot(path)
|
||||
assert snapshot["origin"] == "AKJ"
|
||||
assert snapshot["destination"] == "LON"
|
||||
assert snapshot["departure_date"] == "2099-09-12"
|
||||
assert snapshot["http_status"] == 200
|
||||
assert snapshot["requested_url"] == snapshot["page_url"] == url
|
||||
assert snapshot["flight_script"] == script
|
||||
assert snapshot["page_html"] == html
|
||||
assert snapshot["page_text"] == page_text
|
||||
assert snapshot["truncated_fields"] == []
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o640
|
||||
with pytest.raises(ValueError, match="Unrecognized"):
|
||||
google_flights.parse_results(
|
||||
snapshot["flight_script"], snapshot["requested_url"]
|
||||
)
|
||||
|
||||
|
||||
def test_recording_failure_preserves_lookup_error(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Unavailable diagnostic storage cannot hide the real lookup error."""
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
|
||||
def fail(*args: typing.Any) -> typing.Any:
|
||||
raise ValueError("Original flight failure")
|
||||
|
||||
def cannot_record(*args: typing.Any) -> None:
|
||||
raise PermissionError("No access to diagnostics")
|
||||
|
||||
monkeypatch.setattr(browser, "perform_search", fail)
|
||||
monkeypatch.setattr(browser, "record_failure", cannot_record)
|
||||
with pytest.raises(ValueError, match="Original flight failure"):
|
||||
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
|
||||
|
||||
|
||||
def test_snapshot_retention_and_truncation(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Old and excess snapshots are removed; large raw captures have marked limits."""
|
||||
monkeypatch.setattr(flight_diagnostics, "MAX_RECORDS", 2)
|
||||
first = flight_diagnostics.write_failure(tmp_path, {"error": "old"})
|
||||
assert first is not None
|
||||
old = time.time() - flight_diagnostics.RETENTION_SECONDS - 1
|
||||
os.utime(first, (old, old))
|
||||
second = flight_diagnostics.write_failure(tmp_path, {"error": "recent"})
|
||||
assert second is not None and not first.exists()
|
||||
third = flight_diagnostics.write_failure(tmp_path, {"error": "recent"})
|
||||
fourth = flight_diagnostics.write_failure(
|
||||
tmp_path, {"flight_script": "x" * (1024 * 1024 + 1)}
|
||||
)
|
||||
assert third is not None and fourth is not None
|
||||
assert not second.exists()
|
||||
assert len(list((tmp_path / "errors").glob("*.json.gz"))) == 2
|
||||
snapshot = read_snapshot(fourth)
|
||||
assert snapshot["truncated_fields"] == ["flight_script"]
|
||||
assert len(snapshot["flight_script"].encode()) == 1024 * 1024
|
||||
|
||||
|
||||
def test_storage_error_is_best_effort(tmp_path: Path) -> None:
|
||||
"""An unwritable destination logs a warning and returns without raising."""
|
||||
(tmp_path / "errors").write_text("not a directory")
|
||||
assert flight_diagnostics.write_failure(tmp_path, {"error": "original"}) is None
|
||||
|
||||
|
||||
def test_http_error_captured_once_during_cooldown(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Capture a received 429 body, but don't save duplicate local cooldown refusals."""
|
||||
calls: list[str] = []
|
||||
url = "https://www.google.com/travel/flights/search"
|
||||
body = "Too Many Requests"
|
||||
|
||||
def goto(requested_url: str, **kwargs: typing.Any) -> typing.Any:
|
||||
calls.append(requested_url)
|
||||
return SimpleNamespace(status=429, url=url, text=lambda: body)
|
||||
|
||||
page = SimpleNamespace(
|
||||
url=url,
|
||||
goto=goto,
|
||||
content=lambda: body,
|
||||
locator=lambda selector: SimpleNamespace(inner_text=lambda **kwargs: body),
|
||||
)
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
browser.page = typing.cast(Page, page)
|
||||
monkeypatch.setattr(browser, "start", lambda: browser.page)
|
||||
for _ in range(2):
|
||||
with pytest.raises(flight_search_cache.RateLimitCooldownError):
|
||||
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
|
||||
records = list((tmp_path / "errors").glob("*.json.gz"))
|
||||
assert len(records) == len(calls) == 1
|
||||
snapshot = read_snapshot(records[0])
|
||||
assert snapshot["http_status"] == 429
|
||||
assert snapshot["http_errors"] == [{"status": 429, "url": url, "body": body}]
|
||||
|
||||
|
||||
def test_success_does_not_create_diagnostics(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Successful searches, including empty results, do not accumulate snapshots."""
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
monkeypatch.setattr(browser, "perform_search", lambda *args: [])
|
||||
assert browser.search("AKJ", "LON", date(2099, 9, 12), 1) == []
|
||||
assert not (tmp_path / "errors").exists()
|
||||
83
tests/test_flight_search_cache.py
Normal file
83
tests/test_flight_search_cache.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Offline tests for shared flight pacing, cooldowns and route/date caching."""
|
||||
|
||||
import typing
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import agenda.conference_flights as flights
|
||||
import agenda.flight_search_cache as cache
|
||||
from agenda.types import StrDict
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch: typing.Any) -> tuple[list[float], list[float]]:
|
||||
"""Advance simulated time instead of sleeping or contacting Google."""
|
||||
now = [1000.0]
|
||||
sleeps: list[float] = []
|
||||
|
||||
def current_time() -> float:
|
||||
return now[0]
|
||||
|
||||
def sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
now[0] += seconds
|
||||
|
||||
monkeypatch.setattr(cache, "time", SimpleNamespace(time=current_time, sleep=sleep))
|
||||
return now, sleeps
|
||||
|
||||
|
||||
def test_shared_request_spacing(
|
||||
tmp_path: Path, clock: tuple[list[float], list[float]]
|
||||
) -> None:
|
||||
"""Separate callers share the same three-second spacing state."""
|
||||
cache.wait_for_request(tmp_path)
|
||||
cache.wait_for_request(tmp_path)
|
||||
cache.wait_for_request(tmp_path)
|
||||
assert clock[1] == [3.0, 3.0]
|
||||
assert cache.read_state(tmp_path / "rate-limit.json")["last_request_at"] == 1006.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [[], [{"price": 50}]])
|
||||
def test_day_cache_reuses_results_during_cooldown(
|
||||
tmp_path: Path, clock: tuple[list[float], list[float]], rows: list[StrDict]
|
||||
) -> None:
|
||||
"""Successful and empty route/date results survive a global request cooldown."""
|
||||
calls: list[bool] = []
|
||||
|
||||
def fetch() -> list[StrDict]:
|
||||
calls.append(True)
|
||||
return rows
|
||||
|
||||
assert cache.cached_day(tmp_path, "BRS", "CPH", "2099-01-04", True, fetch) == rows
|
||||
cache.block_requests(tmp_path)
|
||||
assert cache.cached_day(tmp_path, "BRS", "CPH", "2099-01-04", True, fetch) == rows
|
||||
assert len(calls) == 1
|
||||
clock[0][0] += cache.DAY_CACHE_SECONDS + 1
|
||||
cache.cached_day(tmp_path, "BRS", "CPH", "2099-01-04", True, fetch)
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_overlapping_conferences_reuse_day_cache(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Two conferences sharing a departure date fetch that outbound only once."""
|
||||
calls: list[tuple[str, str, date]] = []
|
||||
|
||||
def fetch(
|
||||
origin: str, destination: str, day: date, direct: bool, directory: Path
|
||||
) -> list[StrDict]:
|
||||
calls.append((origin, destination, day))
|
||||
return [{"arrival": day.isoformat() + "T18:00:00", "price": 50}]
|
||||
|
||||
monkeypatch.setattr(flights, "fetch_day", fetch)
|
||||
start = date(2099, 1, 5)
|
||||
for end in (date(2099, 1, 6), date(2099, 1, 7)):
|
||||
path = flights.cache_path(str(tmp_path), start, end, "CPH", False)
|
||||
result = flights.lookup(path, start, end, "CPH", False)
|
||||
assert result["searches"][0]["outbound"]
|
||||
assert len(calls) == 3
|
||||
assert sum(origin == "LON" for origin, _, _ in calls) == 1
|
||||
assert cache.search_directory.get() is None
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
"""Check automatic Geomob recording and failure notifications."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agenda.geomob import GeomobEvent, geomob_email, record_event
|
||||
from agenda.geomob import GeomobEvent, conference_page_links, geomob_email, record_event
|
||||
|
||||
EVENT = GeomobEvent(date(2026, 10, 15), "/post/oct-2026", "#geomobLON")
|
||||
BASE_URL = "https://thegeomob.com"
|
||||
|
|
@ -87,3 +89,44 @@ def test_duplicate_event() -> None:
|
|||
assert "Already recorded" in record_event(EVENT, BASE_URL)
|
||||
assert run.call_count == 4
|
||||
assert run.call_args.args[0] == ["git", "push"]
|
||||
|
||||
|
||||
def test_email_keeps_event_url_and_adds_conference_page(tmp_path: Path) -> None:
|
||||
"""A saved event links to its canonical agenda page alongside the original URL."""
|
||||
event = GeomobEvent(
|
||||
date(2026, 10, 28), "/post/oct-28th-2026-geomobcgn-details", "#geomobCGN"
|
||||
)
|
||||
conference = {
|
||||
"name": "Geomob Cologne",
|
||||
"location": "Cologne",
|
||||
"country": "de",
|
||||
"dates": {"status": "exact", "start": event.date, "end": event.date},
|
||||
"url": BASE_URL + event.href,
|
||||
}
|
||||
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conference]))
|
||||
links = conference_page_links([event], BASE_URL, str(tmp_path))
|
||||
expected = (
|
||||
"https://edwardbetts.com/agenda/conference/2026-10-28/geomob-cologne-cologne"
|
||||
)
|
||||
assert links == {event: expected}
|
||||
subject, body = geomob_email(
|
||||
[event], BASE_URL, {event: "Recorded and pushed."}, links
|
||||
)
|
||||
assert subject == "1 New Geomob Event(s) Announced"
|
||||
assert "URL: https://thegeomob.com/post/oct-28th-2026-geomobcgn-details" in body
|
||||
assert f"Conference page: {expected}" in body
|
||||
assert "Recording: Recorded and pushed." in body
|
||||
changed_date = GeomobEvent(date(2026, 10, 29), event.href, event.hashtag)
|
||||
assert conference_page_links([changed_date], BASE_URL, str(tmp_path)) == {}
|
||||
|
||||
|
||||
def test_missing_conference_data_does_not_hide_event(tmp_path: Path) -> None:
|
||||
"""Unrecorded events still generate their original notification without a broken link."""
|
||||
links = conference_page_links([EVENT], BASE_URL, str(tmp_path))
|
||||
assert links == {}
|
||||
_, body = geomob_email(
|
||||
[EVENT], BASE_URL, {EVENT: "FAILED at add conference"}, links
|
||||
)
|
||||
assert "URL: " + BASE_URL + EVENT.href in body
|
||||
assert "FAILED at add conference" in body
|
||||
assert "Conference page:" not in body
|
||||
|
|
|
|||
335
tests/test_google_flights.py
Normal file
335
tests/test_google_flights.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"""Offline browser and parser coverage, with no requests to Google."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import typing
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Response
|
||||
|
||||
from agenda import (
|
||||
airport_lookup,
|
||||
conference_flights,
|
||||
flight_search_cache,
|
||||
google_flights,
|
||||
)
|
||||
|
||||
|
||||
def itinerary(airline: str = "BA", price: int | None = 424) -> list[typing.Any]:
|
||||
"""A Google itinerary with omitted zero minutes and overnight arrival."""
|
||||
leg: list[typing.Any] = [None] * 23
|
||||
leg[3], leg[6] = "LHR", "LAX"
|
||||
leg[8], leg[10] = [23], [None, 30]
|
||||
leg[20], leg[21] = [2027, 3, 30], [2027, 3, 31]
|
||||
leg[22] = [airline, "135", "BA"]
|
||||
details: list[typing.Any] = [None] * 10
|
||||
details[2], details[9] = [leg], 670
|
||||
return [details, [[None, price]] if price is not None else None]
|
||||
|
||||
|
||||
def result_script() -> str:
|
||||
"""Represent best and other groups, airline metadata, and an unpriced fare."""
|
||||
payload: list[typing.Any] = [None] * 8
|
||||
payload[2] = [[itinerary()]]
|
||||
payload[3] = [[itinerary("AA", None)]]
|
||||
payload[7] = [None, [[], [["BA", "British Airways"], ["AA", "American Airlines"]]]]
|
||||
return (
|
||||
"AF_initDataCallback({key: 'ds:1', data:"
|
||||
+ json.dumps(payload)
|
||||
+ ", sideChannel: {}});"
|
||||
)
|
||||
|
||||
|
||||
def test_results_and_sparse_times() -> None:
|
||||
"""Best flights retain order; codeshares, missing fares and midnight parse."""
|
||||
rows = google_flights.parse_results(result_script(), "https://example.com")
|
||||
assert [row["price"] for row in rows] == [424, None]
|
||||
assert rows[0]["departure"] == "2027-03-30T23:00:00"
|
||||
assert rows[0]["arrival"] == "2027-03-31T00:30:00"
|
||||
assert rows[0]["currency"] == "GBP"
|
||||
assert rows[0]["duration"] == 670
|
||||
assert rows[1]["legs"][0]["airline"] == "American Airlines"
|
||||
assert rows[1]["legs"][0]["operating_airline_code"] == "BA"
|
||||
assert conference_flights.flight_rank(rows[1]) == (0, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script", ["no data", "data:{}", "data:[null,null,[[[[]]]],null]"]
|
||||
)
|
||||
def test_unrecognized_data_is_error(script: str) -> None:
|
||||
"""Broken parsing must not silently cache a successful empty search."""
|
||||
with pytest.raises(ValueError):
|
||||
google_flights.parse_results(script, "https://example.com")
|
||||
|
||||
|
||||
def test_search_url() -> None:
|
||||
"""London includes all six airports; locale, currency and date are explicit."""
|
||||
url = google_flights.search_url("LON", "LAX", date(2027, 3, 30), 1)
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
assert query["hl"] == ["en-GB"]
|
||||
assert query["gl"] == ["GB"]
|
||||
assert query["curr"] == ["GBP"]
|
||||
encoded = query["tfs"][0]
|
||||
data = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
assert all(
|
||||
code.encode() in data for code in (*google_flights.LONDON_AIRPORTS, "LAX")
|
||||
)
|
||||
assert b"2027-03-30" in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,code",
|
||||
[
|
||||
("SFO", "SFO"),
|
||||
("sfo", "SFO"),
|
||||
("Copenhagen, Denmark", "CPH"),
|
||||
("Sacramento, CA", "SMF"),
|
||||
("London", "LON"),
|
||||
],
|
||||
)
|
||||
def test_local_airports(query: str, code: str) -> None:
|
||||
"""Exact codes avoid fuzzy matching; useful scheduled airports come first."""
|
||||
assert airport_lookup.matches(query)[0]["code"] == code
|
||||
assert conference_flights.resolve_airport(query)[0] == code
|
||||
|
||||
|
||||
def test_unknown_iata_is_not_fuzzy() -> None:
|
||||
"""Three letters must not accidentally resolve to another airport."""
|
||||
assert airport_lookup.matches("ZZZ") == []
|
||||
with pytest.raises(ValueError, match="Unknown IATA"):
|
||||
conference_flights.resolve_airport("ZZZ")
|
||||
|
||||
|
||||
def test_browser_429_stops_without_retry(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""A page navigation's 429 blocks other searches without a second navigation."""
|
||||
calls: list[str] = []
|
||||
|
||||
def goto(url: str, **kwargs: typing.Any) -> typing.Any:
|
||||
calls.append(url)
|
||||
return SimpleNamespace(status=429)
|
||||
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
monkeypatch.setattr(browser, "start", lambda: SimpleNamespace(goto=goto))
|
||||
with pytest.raises(flight_search_cache.RateLimitCooldownError):
|
||||
browser.search("LON", "LAX", date(2027, 3, 30), 1)
|
||||
with pytest.raises(flight_search_cache.RateLimitCooldownError):
|
||||
browser.search("LON", "LAX", date(2027, 3, 31), 1)
|
||||
assert len(calls) == 1
|
||||
assert flight_search_cache.cooldown_message(tmp_path)
|
||||
|
||||
|
||||
def test_stops_fallback_and_ba_ranking(tmp_path: Path, monkeypatch: typing.Any) -> None:
|
||||
"""Only fall back to two stops for London; BA preference keeps other airlines."""
|
||||
calls: list[int] = []
|
||||
|
||||
def search(
|
||||
origin: str, destination: str, day: date, max_stops: int
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
calls.append(max_stops)
|
||||
return [
|
||||
{"stops": 2, "legs": [{"airline_code": "BA"}]},
|
||||
{"stops": 1, "legs": [{"airline_code": "AA"}]},
|
||||
{"stops": 1, "legs": [{"airline_code": "BA"}]},
|
||||
{"stops": 0, "legs": [{"airline_code": "AA"}]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(google_flights, "search", search)
|
||||
rows = conference_flights.fetch_day(
|
||||
"LON", "LAX", date(2027, 3, 30), False, tmp_path
|
||||
)
|
||||
assert calls == [1]
|
||||
assert [(r["stops"], r["legs"][0]["airline_code"]) for r in rows] == [
|
||||
(0, "AA"),
|
||||
(1, "BA"),
|
||||
(1, "AA"),
|
||||
]
|
||||
calls.clear()
|
||||
|
||||
def no_results(*args: typing.Any) -> list[dict[str, typing.Any]]:
|
||||
calls.append(args[-1])
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(google_flights, "search", no_results)
|
||||
assert (
|
||||
conference_flights.fetch_day("LON", "LAX", date(2027, 3, 30), False, tmp_path)
|
||||
== []
|
||||
)
|
||||
assert calls == [1, 2]
|
||||
calls.clear()
|
||||
assert (
|
||||
conference_flights.fetch_day("BRS", "CPH", date(2027, 3, 30), True, tmp_path)
|
||||
== []
|
||||
)
|
||||
assert calls == [0]
|
||||
|
||||
|
||||
def test_data_request_429_uses_shared_cooldown(tmp_path: Path) -> None:
|
||||
"""A 429 in the loaded page's Google data request pauses all searches too."""
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
response = SimpleNamespace(
|
||||
status=429, url="https://www.google.com/_/FlightsUi/data/batchexecute"
|
||||
)
|
||||
browser.check_response(typing.cast(Response, response))
|
||||
with pytest.raises(flight_search_cache.RateLimitCooldownError):
|
||||
browser.check_cooldown()
|
||||
assert (
|
||||
flight_search_cache.read_state(tmp_path / "rate-limit.json")["transport"]
|
||||
== flight_search_cache.TRANSPORT
|
||||
)
|
||||
|
||||
|
||||
def test_browser_errors_show_diagnostic_stderr() -> None:
|
||||
"""Long Playwright launch commands must not hide Chromium's actual failure."""
|
||||
exc = RuntimeError(
|
||||
"BrowserType.launch_persistent_context: Target closed\nBrowser logs:\n"
|
||||
"<launching> /usr/bin/chromium "
|
||||
+ "--long-option " * 200
|
||||
+ "\n[pid=123][err] grep: /proc/cpuinfo: No such file or directory\n"
|
||||
"[pid=123][err] The hardware lacks SSE3 support.\n"
|
||||
)
|
||||
details = conference_flights.error_detail(exc)
|
||||
assert "Target closed" in details
|
||||
assert "/proc/cpuinfo: No such file or directory" in details
|
||||
assert "SSE3" in details
|
||||
assert "--long-option" not in details
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query", ["Malmö", "Malmo", "Malmö, Sweden", "MMX,CPH", "mmx, cph"]
|
||||
)
|
||||
def test_malmo_airport_group(query: str) -> None:
|
||||
"""Malmö includes nearby Copenhagen across the border; explicit codes still work."""
|
||||
assert conference_flights.resolve_airport(query)[0] == "MMX,CPH"
|
||||
assert airport_lookup.matches(query)[0]["code"] == "MMX,CPH"
|
||||
assert conference_flights.resolve_airport("MMX")[0] == "MMX"
|
||||
assert conference_flights.resolve_airport("CPH")[0] == "CPH"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("origin,destination", [("BRS", "MMX,CPH"), ("MMX,CPH", "BRS")])
|
||||
def test_combined_airport_search_url(origin: str, destination: str) -> None:
|
||||
"""Outbound and return searches include each airport as its own protobuf entry."""
|
||||
query = parse_qs(
|
||||
urlsplit(
|
||||
google_flights.search_url(origin, destination, date(2026, 10, 19), 0)
|
||||
).query
|
||||
)
|
||||
encoded = query["tfs"][0]
|
||||
data = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
assert b"MMX,CPH" not in data
|
||||
assert all(code.encode() in data for code in ("MMX", "CPH", "BRS"))
|
||||
assert data.count(b"MMX") == data.count(b"CPH") == 1
|
||||
assert b"\x28\x00" in data # Explicit non-stop filter.
|
||||
|
||||
|
||||
def test_bristol_copenhagen_serves_malmo(
|
||||
tmp_path: Path, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""CPH flights satisfy the Bristol preference without searching London or MMX separately."""
|
||||
calls: list[tuple[str, str, bool]] = []
|
||||
|
||||
def search(
|
||||
origin: str, destination: str, day: date, direct: bool
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
calls.append((origin, destination, direct))
|
||||
return [
|
||||
{
|
||||
"arrival": day.isoformat() + "T18:00:00",
|
||||
"legs": [
|
||||
{
|
||||
"origin": "BRS" if origin == "BRS" else "CPH",
|
||||
"destination": "CPH" if origin == "BRS" else "BRS",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(conference_flights, "search_day", search)
|
||||
start, end = date(2099, 1, 5), date(2099, 1, 6)
|
||||
path = conference_flights.cache_path(str(tmp_path), start, end, "MMX,CPH", True)
|
||||
result = conference_flights.lookup(path, start, end, "MMX,CPH", True)
|
||||
assert calls == [("BRS", "MMX,CPH", True), ("MMX,CPH", "BRS", True)]
|
||||
assert [row["origin"] for row in result["searches"]] == ["BRS"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["Bern", "Bern, Switzerland", "BRN,BSL"])
|
||||
def test_bern_includes_basel(query: str) -> None:
|
||||
"""Bern's destination group includes Basel, while BSL alone stays explicit."""
|
||||
code, name = conference_flights.resolve_airport(query)
|
||||
assert code == "BRN,BSL"
|
||||
assert "Basel" in name
|
||||
assert conference_flights.resolve_airport("BSL")[0] == "BSL"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["Bonn", "Bonn, Germany", "CGN,DUS"])
|
||||
def test_bonn_includes_dusseldorf(query: str) -> None:
|
||||
"""Bonn uses Cologne/Bonn and Düsseldorf; DUS remains individually selectable."""
|
||||
code, name = conference_flights.resolve_airport(query)
|
||||
assert code == "CGN,DUS"
|
||||
assert "Düsseldorf" in name
|
||||
assert conference_flights.resolve_airport("DUS")[0] == "DUS"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
[
|
||||
'data:["Requested flight date is too far in the future."]',
|
||||
'data:{"error":"Dates are too far in the future"}',
|
||||
],
|
||||
)
|
||||
def test_provider_future_date_error(script: str) -> None:
|
||||
"""Explicit provider date rejections aren't mistaken for unknown result formats."""
|
||||
with pytest.raises(
|
||||
google_flights.FlightDateUnavailableError, match="too far in the future"
|
||||
):
|
||||
google_flights.parse_results(script, "https://example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("Requested flight date is too far in the future.", "future"),
|
||||
("No flights found", "empty"),
|
||||
(
|
||||
"No non-stop flights found There might not be daily non-stop flights to Bologna (BLQ). Try changing your dates, or search flights with more stops.",
|
||||
"empty",
|
||||
),
|
||||
("No nonstop flights found", "empty"),
|
||||
("No non‑stop flights found", "empty"),
|
||||
("No direct flights found", "empty"),
|
||||
("Something went wrong", "error"),
|
||||
],
|
||||
)
|
||||
def test_unusable_page_data(tmp_path: Path, text: str, expected: str) -> None:
|
||||
"""Only confirmed empty pages become empty results; unrelated failures remain errors."""
|
||||
page = SimpleNamespace(
|
||||
locator=lambda selector: SimpleNamespace(inner_text=lambda: text)
|
||||
)
|
||||
browser = google_flights.BrowserSearch(tmp_path)
|
||||
args = (
|
||||
typing.cast(Page, page),
|
||||
"data:[]",
|
||||
"https://example.com",
|
||||
date(2027, 9, 12),
|
||||
)
|
||||
if expected == "future":
|
||||
with pytest.raises(google_flights.FlightDateUnavailableError):
|
||||
browser.read_results(*args)
|
||||
elif expected == "empty":
|
||||
assert browser.read_results(*args) == []
|
||||
else:
|
||||
with pytest.raises(ValueError, match="12 Sep 2027"):
|
||||
browser.read_results(*args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["Funen", "Funen, Denmark", "BLL,CPH"])
|
||||
def test_funen_includes_copenhagen(query: str) -> None:
|
||||
"""Funen considers both Billund and Copenhagen in a single search."""
|
||||
code, name = conference_flights.resolve_airport(query)
|
||||
assert code == "BLL,CPH"
|
||||
assert "Billund" in name and "Copenhagen" in name
|
||||
Loading…
Add table
Add a link
Reference in a new issue