Add conference detail pages and cached flight searches

This commit is contained in:
Edward Betts 2026-10-02 10:46:26 +01:00
parent 6b253f2d04
commit dcaf723336
24 changed files with 12198 additions and 12 deletions

View 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 [])

View 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()

View 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

View file

@ -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

View 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