Add Eurostar Plus prices and NR advance fare support
- Eurostar scraper now fetches both Standard and Plus (PLUS class code) prices/seats in a single API call; each service dict gains plus_price and plus_seats fields - GWR fares scraper gains fetch_advance() which makes two sets of paginated calls (standard advance + first-class advance) and returns cheapest per departure; shared _run_pages() generator reduces duplication in fetch() - New /api/advance_fares/<station_crs>/<travel_date> endpoint returns advance fares as JSON, cached for 24 hours - Results page gains NR ticket selector (Walk-on / Std Advance / 1st Advance) and Eurostar selector (Standard / Plus); total column is JS-computed from the selected combination with cheapest/priciest highlighting - Load advance prices button fetches the API lazily; if advance fares are already cached they are embedded in the page and applied on load so the button is hidden automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5583a20143
commit
89a536dfd3
8 changed files with 515 additions and 83 deletions
|
|
@ -33,6 +33,8 @@ def _stub_data(monkeypatch, prices=None, gwr_fares=None):
|
|||
'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,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
|
@ -173,7 +175,7 @@ def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch
|
|||
|
||||
|
||||
def test_results_shows_eurostar_price_and_total(monkeypatch):
|
||||
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:05–08:25 window)
|
||||
# 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()
|
||||
|
||||
|
|
@ -182,7 +184,10 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
|
|||
|
||||
assert resp.status_code == 200
|
||||
assert '£59' in html # Eurostar Standard price
|
||||
assert '£197.70' in html # Anytime £138.70 + ES £59
|
||||
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_shows_unreachable_service_when_no_trips(monkeypatch):
|
||||
|
|
@ -214,3 +219,119 @@ def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
|
|||
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_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
|
||||
|
|
|
|||
|
|
@ -6,16 +6,25 @@ def _gql_response(journeys: list) -> dict:
|
|||
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
|
||||
|
||||
|
||||
def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES') -> dict:
|
||||
def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES',
|
||||
plus_price=None, plus_seats=None) -> dict:
|
||||
fares = [{
|
||||
'classOfService': {'code': 'STANDARD'},
|
||||
'prices': {'displayPrice': price},
|
||||
'seats': seats,
|
||||
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
|
||||
if service_name else [],
|
||||
}]
|
||||
if plus_price is not None or plus_seats is not None:
|
||||
fares.append({
|
||||
'classOfService': {'code': 'PLUS'},
|
||||
'prices': {'displayPrice': plus_price},
|
||||
'seats': plus_seats,
|
||||
'legs': [],
|
||||
})
|
||||
return {
|
||||
'timing': {'departureTime': departs, 'arrivalTime': arrives},
|
||||
'fares': [{
|
||||
'classOfService': {'code': 'STANDARD'},
|
||||
'prices': {'displayPrice': price},
|
||||
'seats': seats,
|
||||
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
|
||||
if service_name else [],
|
||||
}],
|
||||
'fares': fares,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -34,6 +43,27 @@ def test_parse_graphql_single_journey():
|
|||
assert s['train_number'] == 'ES 9014'
|
||||
assert s['price'] == 156.0
|
||||
assert s['seats'] == 37
|
||||
assert s['plus_price'] is None
|
||||
assert s['plus_seats'] is None
|
||||
|
||||
|
||||
def test_parse_graphql_standard_premier_price():
|
||||
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014',
|
||||
plus_price=220, plus_seats=12)])
|
||||
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||
assert len(services) == 1
|
||||
s = services[0]
|
||||
assert s['price'] == 156.0
|
||||
assert s['seats'] == 37
|
||||
assert s['plus_price'] == 220.0
|
||||
assert s['plus_seats'] == 12
|
||||
|
||||
|
||||
def test_parse_graphql_plus_price_none_when_not_returned():
|
||||
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37)])
|
||||
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||
assert services[0]['plus_price'] is None
|
||||
assert services[0]['plus_seats'] is None
|
||||
|
||||
|
||||
def test_parse_graphql_half_pound_price():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue