971 lines
32 KiB
Python
971 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('name="destination"') == len(app_module.DESTINATIONS)
|
|
assert 'id="dest-rotterdam"' in html
|
|
assert "<strong>Cologne Hbf</strong><span>Cologne Hbf</span>" 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&max_connection=120"
|
|
in html
|
|
)
|
|
assert (
|
|
"/results/BRI/rotterdam/2026-04-10?min_connection=60&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 → 08:45" in html
|
|
assert "10:01 → 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&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 → Bristol Temple Meads" in html
|
|
assert "15:12 → 16:30" in html
|
|
assert "17:15 → 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 → Paris Gare du Nord" in html
|
|
assert "Return: Paris Gare du Nord → 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 → KX 09:25" in html
|
|
assert "next 09:15 → KX 09:30" in html
|
|
assert "Circle 16:40 → PAD 16:55" in html
|
|
assert "next 16:45 → 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
|