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 'Paris Gare du Nord' 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): 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 → 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 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 'Bristol Temple Meads to Lille Europe via Eurostar' in html assert '' in html assert ( '' ) in html assert '' 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 → 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): 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 → Paris Gare du Nord' in html assert 'Return: Paris Gare du Nord → 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 → 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">⚠️' 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