paddington-eurostar/tests/test_app.py
Edward Betts a859b96a23 Split return date nav into separate outbound/return rows; show earlier tube option on inbound
For return journeys, replace the single combined date navigation row with two
separate rows so outbound and return dates can be adjusted independently.

For inbound underground options, show one service before the earliest catchable
(as an "aim for this" option) rather than the next service after it, which
often arrived too late to connect with the GWR train.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:09:36 +01:00

684 lines
28 KiB
Python

from datetime import datetime
import app as app_module
import trip_planner as trip_planner_module
def _client():
app_module.app.config['TESTING'] = True
return app_module.app.test_client()
def _stub_data(monkeypatch, prices=None, gwr_fares=None):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.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(
app_module.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():
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():
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():
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():
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):
_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):
travel_date = "2026-06-22"
cache = {
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(
app_module.rtt_scraper,
'fetch',
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should use weekday NR cache")),
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should use weekday Eurostar cache")),
)
monkeypatch.setattr(
app_module.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):
travel_date = "2026-06-22"
cache = {
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(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:05', 'arrive_paddington': '08:50', 'headcode': '1A24'},
],
)
monkeypatch.setattr(
app_module.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(
app_module.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):
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 = {
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(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': nr_timetable,
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date: [
{**es_timetable[0], 'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5},
],
)
monkeypatch.setattr(
app_module.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):
def fail_fetch(*args, **kwargs):
raise AssertionError("progressive shell should not fetch data")
monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch)
monkeypatch.setattr(app_module.eurostar_scraper, 'fetch', fail_fetch)
monkeypatch.setattr(app_module.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):
def fail_fetch(*args, **kwargs):
raise AssertionError("progressive shell should not fetch data")
monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch)
monkeypatch.setattr(app_module.eurostar_scraper, 'fetch_return', fail_fetch)
monkeypatch.setattr(app_module.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):
_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):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.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(
app_module.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):
# 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(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.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):
# 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
assert '£138.70' in html # Walk-on price shown in NR cell
# Total (£197.70) is computed client-side; verify data attributes carry the right values
assert 'data-walkon="138.7"' in html
assert 'data-es-std="59"' in html
def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.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(
app_module.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(
app_module.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):
# 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(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.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):
_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):
_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):
advance_data = {
'07:00': {
'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'},
'advance_1st': None,
}
}
def fake_get_cached(key, ttl=None):
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(
app_module.rtt_scraper, 'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.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):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.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(
app_module.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(
app_module.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):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.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(
app_module.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(
app_module.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(
trip_planner_module.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 'earlier 16:40 &rarr; PAD 16:55' in html
assert 'Circle 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):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.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(monkeypatch):
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):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.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