paddington-eurostar/tests/test_app.py

678 lines
27 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
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': (
[
(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 to Friday 17 April 2026' in html
assert '/results/BRI/paris/2026-04-09/return/2026-04-16' in html
assert '/results/BRI/paris/2026-04-11/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):
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