paddington-eurostar/tests/test_app.py
Edward Betts 13c4341f3a Add full type annotations and black formatting across all modules
Annotated all functions with mypy --strict-compatible types (-> None, dict[str,
Any], Generator types, etc.), added # type: ignore for untyped third-party libs
(lxml), and reformatted with black. All 18 source files now pass mypy --strict
with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:48:53 +01:00

970 lines
32 KiB
Python

from datetime import datetime
from typing import Any
import app as app_module
import trip_planner as trip_planner_module
rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined]
gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined]
eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined]
circle_line: Any = trip_planner_module.circle_line # type: ignore[attr-defined]
def _client() -> Any:
app_module.app.config["TESTING"] = True
return app_module.app.test_client()
def _stub_data(monkeypatch: Any, prices: Any = None, gwr_fares: Any = None) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: gwr_fares
or {"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}},
)
p = (prices or {}).get("10:01", {})
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": p.get("price") if isinstance(p, dict) else None,
"seats": p.get("seats") if isinstance(p, dict) else None,
"plus_price": p.get("plus_price") if isinstance(p, dict) else None,
"plus_seats": p.get("plus_seats") if isinstance(p, dict) else None,
},
],
)
def test_index_shows_station_dropdown_and_destination_radios() -> None:
client = _client()
resp = client.get("/")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Departure point" in html
assert "Bristol Temple Meads" in html
assert 'name="station_crs"' in html
assert html.count('type="radio"') == len(app_module.DESTINATIONS)
assert "destination-rotterdam" in html
def test_search_redirects_to_results_with_selected_params() -> None:
client = _client()
resp = client.get(
"/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI"
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith(
"/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120"
)
def test_search_redirects_return_with_return_date() -> None:
client = _client()
resp = client.get(
"/search?journey_type=return&destination=paris&travel_date=2026-04-10&return_date=2026-04-17&station_crs=BRI"
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith(
"/results/BRI/paris/2026-04-10/return/2026-04-17"
)
def test_nr_weekday_cache_key_includes_timetable_period() -> None:
key = app_module._nr_weekday_cache_key("to_paddington", "BRI", "2026-06-22")
assert key == "weekday_rtt_to_paddington_BRI_2026-05-17_2026-12-12_mon"
def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None:
_stub_data(monkeypatch)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Switch destination for Friday 10 April 2026" in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert (
"/results/BRI/brussels/2026-04-10?min_connection=60&amp;max_connection=120"
in html
)
assert (
"/results/BRI/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120"
in html
)
assert "ES 9014" in html
def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> None:
travel_date = "2026-06-22"
cache: dict[str, Any] = {
app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
app_module._eurostar_weekday_cache_key(
"outbound", travel_date, "Paris Gare du Nord"
): [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
"train_number": "ES 9014",
},
],
}
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("should use weekday NR cache")
),
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("should use weekday Eurostar cache")
),
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("should stream prices later")
),
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-06-22?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "07:00 &rarr; 08:45" in html
assert "10:01 &rarr; 13:34" in html
assert "ES 9014" in html
assert "checking exact timetable" in html
assert "/api/results_refresh/BRI/paris/2026-06-22" in html
assert "refreshFullResults()" in html
assert "window.location.reload()" not in html
assert "Eurostar prices not yet available" not in html
def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any) -> None:
travel_date = "2026-06-22"
cache: dict[str, Any] = {
app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
app_module._eurostar_weekday_cache_key(
"outbound", travel_date, "Paris Gare du Nord"
): [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
"train_number": "ES 9014",
},
],
}
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
monkeypatch.setattr(
app_module, "set_cached", lambda key, data: cache.__setitem__(key, data)
)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:05",
"arrive_paddington": "08:50",
"headcode": "1A24",
},
],
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("reload should stop before fare fetch")
),
)
client = _client()
resp = client.get("/api/results_refresh/BRI/paris/2026-06-22")
body = resp.get_data(as_text=True)
assert resp.status_code == 200
assert '"type": "reload"' in body
assert (
cache[app_module._nr_exact_cache_key("to_paddington", "BRI", travel_date)][0][
"depart_bristol"
]
== "07:05"
)
assert (
cache[app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date)][0][
"depart_bristol"
]
== "07:05"
)
def test_results_refresh_streams_prices_when_timetable_matches(
monkeypatch: Any,
) -> None:
travel_date = "2026-06-22"
nr_timetable = [
{"depart_bristol": "07:00", "arrive_paddington": "08:45", "headcode": "1A23"},
]
es_timetable = [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
"train_number": "ES 9014",
},
]
cache: dict[str, Any] = {
app_module._nr_weekday_cache_key(
"to_paddington", "BRI", travel_date
): nr_timetable,
app_module._eurostar_weekday_cache_key(
"outbound", travel_date, "Paris Gare du Nord"
): es_timetable,
}
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
monkeypatch.setattr(
app_module, "set_cached", lambda key, data: cache.__setitem__(key, data)
)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": nr_timetable,
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
**es_timetable[0],
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
},
)
client = _client()
resp = client.get("/api/results_refresh/BRI/paris/2026-06-22")
body = resp.get_data(as_text=True)
assert resp.status_code == 200
assert '"type": "reload"' not in body
assert '"type": "eurostar_prices"' in body
assert '"main:10:01"' in body
assert '"price": 59' in body
assert '"type": "walkon_fares"' in body
assert '"price": 138.7' in body
def test_results_progressive_shell_loads_without_scraping(monkeypatch: Any) -> None:
def fail_fetch(*args: Any, **kwargs: Any) -> None:
raise AssertionError("progressive shell should not fetch data")
monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch)
monkeypatch.setattr(eurostar_scraper, "fetch", fail_fetch)
monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
client = _client()
resp = client.get("/results/BRI/paris/2026-04-10?progressive=1")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Loading train times and fares" in html
assert "render=full" in html
def test_return_progressive_shell_formats_return_date(monkeypatch: Any) -> None:
def fail_fetch(*args: Any, **kwargs: Any) -> None:
raise AssertionError("progressive shell should not fetch data")
monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch)
monkeypatch.setattr(eurostar_scraper, "fetch_return", fail_fetch)
monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
client = _client()
resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17?progressive=1")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Friday 10 April 2026 to Friday 17 April 2026" in html
assert "to 2026-04-17" not in html
def test_results_title_and_social_meta_include_destination(monkeypatch: Any) -> None:
_stub_data(monkeypatch)
client = _client()
resp = client.get(
"/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "<title>Bristol Temple Meads to Lille Europe via Eurostar</title>" in html
assert (
'<meta property="og:title" content="Bristol Temple Meads to Lille Europe via Eurostar">'
in html
)
assert (
'<meta property="og:description" content="Train options from Bristol Temple Meads '
'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">'
) in html
assert (
'<meta property="og:url" content="http://localhost/results/BRI/lille/2026-04-10?min_connection=60&amp;max_connection=120">'
in html
)
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(
monkeypatch: Any,
) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:30",
"headcode": "1A01",
},
{
"depart_bristol": "07:05",
"arrive_paddington": "08:36",
"headcode": "1A02",
},
{
"depart_bristol": "07:10",
"arrive_paddington": "08:46",
"headcode": "1A03",
},
{
"depart_bristol": "07:15",
"arrive_paddington": "08:56",
"headcode": "1A04",
},
{
"depart_bristol": "07:20",
"arrive_paddington": "09:06",
"headcode": "1A05",
},
],
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "09:30",
"arrive_destination": "11:50",
"destination": destination,
"train_number": "ES 1001",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "09:40",
"arrive_destination": "12:00",
"destination": destination,
"train_number": "ES 1002",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "09:50",
"arrive_destination": "12:20",
"destination": destination,
"train_number": "ES 1003",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "10:00",
"arrive_destination": "12:35",
"destination": destination,
"train_number": "ES 1004",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "10:10",
"arrive_destination": "12:45",
"destination": destination,
"train_number": "ES 1005",
"price": None,
"seats": None,
},
],
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert html.count('title="Fastest journey"') == 2
assert html.count('title="Slowest journey"') == 2
assert "4h 50m ⚡" in html
assert "4h 55m ⚡" in html
assert "5h 20m 🐢" in html
assert "5h 25m 🐢" in html
assert "5h 10m ⚡" not in html
assert "5h 10m 🐢" not in html
def test_results_shows_only_pre_first_reachable_unreachable_services(
monkeypatch: Any,
) -> None:
# GWR arrives 08:45; min=60 → earliest viable Eurostar 09:45; max=120 → latest 10:45.
# 09:30 too early → shown as "Too early"
# 10:15 reachable → shown as a trip (needs circle line XML, so not tested here)
# 12:30 after first reachable → hidden
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:00",
"destination": destination,
"train_number": "ES 9001",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "10:15",
"arrive_destination": "13:40",
"destination": destination,
"train_number": "ES 9002",
"price": None,
"seats": None,
},
{
"depart_st_pancras": "12:30",
"arrive_destination": "15:55",
"destination": destination,
"train_number": "ES 9003",
"price": None,
"seats": None,
},
],
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "ES 9001" in html # before first reachable → shown
assert "Too early" in html
assert "ES 9003" not in html # after first reachable → hidden
def test_results_shows_eurostar_price_and_total(monkeypatch: Any) -> None:
# 07:00 on Friday 2026-04-10 → Anytime £138.70 walk-on + ES £59.00
_stub_data(monkeypatch, prices={"10:01": {"price": 59, "seats": 42}})
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "£59" in html # Eurostar Standard price in initial render
assert "£138.70" not in html # Walk-on price is streamed, not server-rendered
assert 'data-es-std="59"' in html
assert "/api/walkon_fares/BRI/" in html # client will fetch walk-on fares
def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda s, d: {
"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
"07:30": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
},
)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:30",
"headcode": "1A01",
},
{
"depart_bristol": "07:30",
"arrive_paddington": "09:00",
"headcode": "1A02",
},
],
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'data-row-key="main:07:00:10:01"' in html
assert 'data-row-key="main:07:30:10:01"' in html
assert '"main:07:00:10:01"' in html
assert '"main:07:30:10:01"' in html
def test_results_shows_unreachable_service_when_no_trips(monkeypatch: Any) -> None:
# Only one Eurostar at 09:30; GWR arrives 08:45 with min=60 → unreachable.
# No trips at all, so the unreachable service is shown as "Too early".
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:00",
"destination": destination,
"train_number": "ES 9001",
"price": None,
"seats": None,
},
],
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "ES 9001" in html
assert "Too early" in html
assert "No valid journeys found." not in html
def test_results_shows_eurostar_plus_price(monkeypatch: Any) -> None:
_stub_data(
monkeypatch,
prices={"10:01": {"price": 59, "seats": 42, "plus_price": 89, "plus_seats": 5}},
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "£59" in html # Standard price
assert "£89" in html # Plus price
assert "Plus" in html # Plus label
def test_results_selectors_present(monkeypatch: Any) -> None:
_stub_data(monkeypatch)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "nr-type-select" in html
assert "es-type-select" in html
assert "Load advance prices" in html
assert "Plus" in html
def test_results_preloads_cached_advance_fares(monkeypatch: Any) -> None:
advance_data = {
"07:00": {
"advance_std": {"ticket": "Advance Single", "price": 45.0, "code": "ADV"},
"advance_1st": None,
}
}
def fake_get_cached(key: str, ttl: Any = None) -> Any:
if "gwr_advance" in key:
return advance_data
return None
monkeypatch.setattr(app_module, "get_cached", fake_get_cached)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": None,
"seats": None,
"plus_price": None,
"plus_seats": None,
},
],
)
client = _client()
resp = client.get(
"/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120"
)
html = resp.get_data(as_text=True)
assert resp.status_code == 200
# Cached advance fares are embedded in the page JS
assert '"advance_std"' in html
assert "45.0" in html
# Button is absent (hidden via cachedAdvanceFares check in JS)
# The JS will hide it on load; the data is present for applyAdvanceFares()
assert "cachedAdvanceFares" in html
def test_results_inbound_uses_reverse_legs(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch_from_paddington",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date, direction="to_paddington": {
"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}
},
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date, direction="outbound": [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": destination,
"train_number": "ES 9035",
"price": 49,
"seats": 43,
"plus_price": None,
"plus_seats": None,
},
],
)
client = _client()
resp = client.get("/results/BRI/paris/2026-04-10?journey_type=inbound")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Paris Gare du Nord &rarr; Bristol Temple Meads" in html
assert "15:12 &rarr; 16:30" in html
assert "17:15 &rarr; 18:55" in html
assert "ES 9035" in html
def test_results_return_renders_outbound_and_inbound_tables(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
rtt_scraper,
"fetch_from_paddington",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date, direction="to_paddington": {
"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"},
},
)
monkeypatch.setattr(
eurostar_scraper,
"fetch_return",
lambda destination, outbound_date, return_date: {
"outbound": [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": None,
"plus_seats": None,
},
],
"inbound": [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": destination,
"train_number": "ES 9035",
"price": 49,
"seats": 43,
"plus_price": None,
"plus_seats": None,
},
],
},
)
monkeypatch.setattr(
circle_line,
"upcoming_services",
lambda earliest_board, count=2, direction="pad_to_kx", preceding=0: (
[
(datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)),
(datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)),
]
if direction == "pad_to_kx"
else [
(datetime(2026, 4, 17, 16, 40), datetime(2026, 4, 17, 16, 55)),
(datetime(2026, 4, 17, 16, 45), datetime(2026, 4, 17, 17, 0)),
]
),
)
client = _client()
resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "Outbound: Bristol Temple Meads &rarr; Paris Gare du Nord" in html
assert "Return: Paris Gare du Nord &rarr; Bristol Temple Meads" in html
assert "Friday 10 April 2026" in html
assert "Friday 17 April 2026" in html
assert "/results/BRI/paris/2026-04-09/return/2026-04-17" in html
assert "/results/BRI/paris/2026-04-11/return/2026-04-17" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-16" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-18" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html
assert "journey_type=return" not in html
assert "return_date=2026-04-17" not in html
assert "Circle 09:10 &rarr; KX 09:25" in html
assert "next 09:15 &rarr; KX 09:30" in html
assert "Circle 16:40 &rarr; PAD 16:55" in html
assert "next 16:45 &rarr; PAD 17:00" in html
assert 'title="Tight connection">⚠️</span>' in html
assert "ES 9014" in html
assert "ES 9035" in html
def test_api_advance_fares_returns_json(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 45.0,
"code": "ADV",
},
"advance_1st": {"ticket": "1st Advance", "price": 65.0, "code": "AFA"},
}
},
)
client = _client()
resp = client.get("/api/advance_fares/BRI/2026-04-10")
data = resp.get_json()
assert resp.status_code == 200
assert "07:00" in data
assert data["07:00"]["advance_std"]["price"] == 45.0
assert data["07:00"]["advance_1st"]["price"] == 65.0
def test_api_advance_fares_404_for_unknown_station() -> None:
client = _client()
resp = client.get("/api/advance_fares/XYZ/2026-04-10")
assert resp.status_code == 404
def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance",
lambda s, d: (_ for _ in ()).throw(Exception("network error")),
)
client = _client()
resp = client.get("/api/advance_fares/BRI/2026-04-10")
data = resp.get_json()
assert resp.status_code == 500
assert "error" in data