- 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>
122 lines
4.3 KiB
Python
122 lines
4.3 KiB
Python
import pytest
|
|
from scraper.eurostar import _parse_graphql, search_url
|
|
|
|
|
|
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',
|
|
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': fares,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_graphql
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_parse_graphql_single_journey():
|
|
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')])
|
|
services = _parse_graphql(data, 'Paris Gare du Nord')
|
|
assert len(services) == 1
|
|
s = services[0]
|
|
assert s['depart_st_pancras'] == '09:31'
|
|
assert s['arrive_destination'] == '12:55'
|
|
assert s['destination'] == 'Paris Gare du Nord'
|
|
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():
|
|
data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
|
|
services = _parse_graphql(data, 'Amsterdam Centraal')
|
|
assert services[0]['price'] == 192.5
|
|
|
|
|
|
def test_parse_graphql_null_price():
|
|
data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)])
|
|
services = _parse_graphql(data, 'Amsterdam Centraal')
|
|
assert services[0]['price'] is None
|
|
assert services[0]['seats'] == 0
|
|
|
|
|
|
def test_parse_graphql_sorted_by_departure():
|
|
data = _gql_response([
|
|
_journey('10:31', '13:55'),
|
|
_journey('07:31', '10:59'),
|
|
])
|
|
services = _parse_graphql(data, 'Paris Gare du Nord')
|
|
assert services[0]['depart_st_pancras'] == '07:31'
|
|
assert services[1]['depart_st_pancras'] == '10:31'
|
|
|
|
|
|
def test_parse_graphql_deduplicates_same_departure_time():
|
|
data = _gql_response([
|
|
_journey('06:16', '11:09', price=None, seats=0),
|
|
_journey('06:16', '11:09', price=None, seats=0),
|
|
_journey('06:16', '11:09', price=None, seats=0),
|
|
])
|
|
services = _parse_graphql(data, 'Amsterdam Centraal')
|
|
assert len(services) == 1
|
|
|
|
|
|
def test_parse_graphql_no_legs_gives_empty_train_number():
|
|
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')])
|
|
services = _parse_graphql(data, 'Paris Gare du Nord')
|
|
assert services[0]['train_number'] == ''
|
|
|
|
|
|
def test_parse_graphql_empty_journeys():
|
|
data = _gql_response([])
|
|
assert _parse_graphql(data, 'Paris Gare du Nord') == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# search_url
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_search_url():
|
|
url = search_url('Paris Gare du Nord', '2026-04-10')
|
|
assert url == (
|
|
'https://www.eurostar.com/search/uk-en'
|
|
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
|
|
)
|