Show Eurostar Standard prices and total journey cost on results page
Fetches prices via the site-api.eurostar.com GraphQL gateway (NewBookingSearch operation, discovered with Playwright). Adds fetch_prices() to scraper/eurostar.py using requests, caches results, annotates each trip with eurostar_price and total_price, and shows an ES Std column plus total cost (duration + price) in the results table. The Transfer column is hidden on small screens for mobile usability. Closes #4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
804fcedfad
commit
0dee942e16
4 changed files with 191 additions and 12 deletions
29
app.py
29
app.py
|
|
@ -8,6 +8,7 @@ from cache import get_cached, set_cached
|
||||||
import scraper.eurostar as eurostar_scraper
|
import scraper.eurostar as eurostar_scraper
|
||||||
import scraper.realtime_trains as rtt_scraper
|
import scraper.realtime_trains as rtt_scraper
|
||||||
from trip_planner import combine_trips, find_unreachable_morning_eurostars
|
from trip_planner import combine_trips, find_unreachable_morning_eurostars
|
||||||
|
from scraper.eurostar import fetch_prices as fetch_eurostar_prices
|
||||||
|
|
||||||
RTT_PADDINGTON_URL = (
|
RTT_PADDINGTON_URL = (
|
||||||
"https://www.realtimetrains.co.uk/search/detailed/"
|
"https://www.realtimetrains.co.uk/search/detailed/"
|
||||||
|
|
@ -80,10 +81,12 @@ def results(slug, travel_date):
|
||||||
|
|
||||||
rtt_cache_key = f"rtt_{travel_date}"
|
rtt_cache_key = f"rtt_{travel_date}"
|
||||||
es_cache_key = f"eurostar_{travel_date}_{destination}"
|
es_cache_key = f"eurostar_{travel_date}_{destination}"
|
||||||
|
prices_cache_key = f"eurostar_prices_{travel_date}_{destination}"
|
||||||
|
|
||||||
cached_rtt = get_cached(rtt_cache_key)
|
cached_rtt = get_cached(rtt_cache_key)
|
||||||
cached_es = get_cached(es_cache_key)
|
cached_es = get_cached(es_cache_key)
|
||||||
from_cache = bool(cached_rtt and cached_es)
|
cached_prices = get_cached(prices_cache_key)
|
||||||
|
from_cache = bool(cached_rtt and cached_es and cached_prices)
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
|
|
||||||
|
|
@ -108,7 +111,28 @@ def results(slug, travel_date):
|
||||||
msg = f"Could not fetch Eurostar times: {e}"
|
msg = f"Could not fetch Eurostar times: {e}"
|
||||||
error = f"{error}; {msg}" if error else msg
|
error = f"{error}; {msg}" if error else msg
|
||||||
|
|
||||||
|
if cached_prices:
|
||||||
|
eurostar_prices = cached_prices
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
eurostar_prices = fetch_eurostar_prices(destination, travel_date)
|
||||||
|
set_cached(prices_cache_key, eurostar_prices)
|
||||||
|
except Exception as e:
|
||||||
|
eurostar_prices = {}
|
||||||
|
msg = f"Could not fetch Eurostar prices: {e}"
|
||||||
|
error = f"{error}; {msg}" if error else msg
|
||||||
|
|
||||||
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
|
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
|
||||||
|
|
||||||
|
# Annotate each trip with Eurostar Standard price and total cost
|
||||||
|
for trip in trips:
|
||||||
|
es_price = eurostar_prices.get(trip['depart_st_pancras'])
|
||||||
|
trip['eurostar_price'] = es_price
|
||||||
|
if es_price is not None:
|
||||||
|
trip['total_price'] = trip['ticket_price'] + es_price
|
||||||
|
else:
|
||||||
|
trip['total_price'] = None
|
||||||
|
|
||||||
unreachable_morning_services = find_unreachable_morning_eurostars(
|
unreachable_morning_services = find_unreachable_morning_eurostars(
|
||||||
gwr_trains,
|
gwr_trains,
|
||||||
eurostar_trains,
|
eurostar_trains,
|
||||||
|
|
@ -116,6 +140,9 @@ def results(slug, travel_date):
|
||||||
min_connection,
|
min_connection,
|
||||||
max_connection,
|
max_connection,
|
||||||
)
|
)
|
||||||
|
for svc in unreachable_morning_services:
|
||||||
|
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
|
||||||
|
|
||||||
result_rows = sorted(
|
result_rows = sorted(
|
||||||
[{'row_type': 'trip', **trip} for trip in trips]
|
[{'row_type': 'trip', **trip} for trip in trips]
|
||||||
+ [{'row_type': 'unreachable', **service} for service in unreachable_morning_services],
|
+ [{'row_type': 'unreachable', **service} for service in unreachable_morning_services],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Scrape Eurostar timetable via httpx.
|
Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API.
|
||||||
|
|
||||||
The route-specific timetable pages are Next.js SSR — all departure data is
|
Timetable: route-specific pages are Next.js SSR — all departure data is
|
||||||
embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed.
|
embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed.
|
||||||
|
|
||||||
URL pattern:
|
URL pattern:
|
||||||
|
|
@ -12,10 +12,19 @@ Data path: props.pageProps.pageData.liveDepartures[]
|
||||||
.origin.model.scheduledDepartureDateTime → London departure
|
.origin.model.scheduledDepartureDateTime → London departure
|
||||||
.destination.model.scheduledArrivalDateTime → destination arrival
|
.destination.model.scheduledArrivalDateTime → destination arrival
|
||||||
(already filtered to the requested stop, not the final stop)
|
(already filtered to the requested stop, not the final stop)
|
||||||
|
|
||||||
|
Prices: POST https://site-api.eurostar.com/gateway (GraphQL, operationName
|
||||||
|
NewBookingSearch). The `journeys[].fares[]` array contains one entry per
|
||||||
|
class of service; we extract the Eurostar Standard (classOfService.code ==
|
||||||
|
"STANDARD") displayPrice for 1 adult, in GBP.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import string
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import requests
|
||||||
|
|
||||||
DEFAULT_UA = (
|
DEFAULT_UA = (
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
|
@ -90,3 +99,105 @@ def fetch(destination: str, travel_date: str,
|
||||||
r = client.get(url, params={'date': travel_date})
|
r = client.get(url, params={'date': travel_date})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return _parse(r.text, destination)
|
return _parse(r.text, destination)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Price fetching via site-api.eurostar.com GraphQL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
|
||||||
|
|
||||||
|
# Minimal query requesting only timing + Eurostar Standard fare price.
|
||||||
|
# Variable names and inline argument names match what the site sends so the
|
||||||
|
# server-side query planner sees a familiar shape.
|
||||||
|
_GQL_PRICES = (
|
||||||
|
"query NewBookingSearch("
|
||||||
|
"$origin:String!,$destination:String!,$outbound:String!,"
|
||||||
|
"$currency:Currency!,$adult:Int,"
|
||||||
|
"$filteredClassesOfService:[ClassOfServiceEnum]"
|
||||||
|
"){"
|
||||||
|
"journeySearch("
|
||||||
|
"outboundDate:$outbound origin:$origin destination:$destination"
|
||||||
|
" adults:$adult currency:$currency"
|
||||||
|
" productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\""
|
||||||
|
" adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0"
|
||||||
|
" infants:0 adultsWheelchair:0 childrenWheelchair:0 guideDogs:0"
|
||||||
|
" wheelchairCompanions:0 nonWheelchairCompanions:0"
|
||||||
|
" isAftersales:false multipleFlexibility:true showAllSummatedFares:false"
|
||||||
|
" seniorsAges:[] prioritiseShortHaulODTrains:true"
|
||||||
|
"){"
|
||||||
|
"outbound{"
|
||||||
|
"journeys("
|
||||||
|
"hideIndirectTrainsWhenDisruptedAndCancelled:false"
|
||||||
|
" hideDepartedTrains:true"
|
||||||
|
" hideExternalCarrierTrains:true"
|
||||||
|
" hideDirectExternalCarrierTrains:true"
|
||||||
|
"){"
|
||||||
|
"timing{departureTime:departs __typename}"
|
||||||
|
"fares(filteredClassesOfService:$filteredClassesOfService){"
|
||||||
|
"classOfService{code __typename}"
|
||||||
|
"prices{displayPrice __typename}"
|
||||||
|
"seats __typename"
|
||||||
|
"}"
|
||||||
|
"__typename"
|
||||||
|
"}"
|
||||||
|
"__typename"
|
||||||
|
"}"
|
||||||
|
"__typename"
|
||||||
|
"}"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_cid() -> str:
|
||||||
|
chars = string.ascii_letters + string.digits
|
||||||
|
return 'SRCH-' + ''.join(random.choices(chars, k=22))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
|
||||||
|
"""
|
||||||
|
Return Eurostar Standard prices for every departure on travel_date.
|
||||||
|
|
||||||
|
Result: {depart_st_pancras: price_gbp_int_or_None}
|
||||||
|
None means the class is sold out or unavailable for that departure.
|
||||||
|
"""
|
||||||
|
dest_id = DESTINATION_STATION_IDS[destination]
|
||||||
|
headers = {
|
||||||
|
'User-Agent': DEFAULT_UA,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'en-GB',
|
||||||
|
'Referer': 'https://www.eurostar.com/',
|
||||||
|
'x-platform': 'web',
|
||||||
|
'x-market-code': 'uk',
|
||||||
|
'x-source-url': 'search-app/',
|
||||||
|
'cid': _generate_cid(),
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
'operationName': 'NewBookingSearch',
|
||||||
|
'variables': {
|
||||||
|
'origin': ORIGIN_STATION_ID,
|
||||||
|
'destination': dest_id,
|
||||||
|
'outbound': travel_date,
|
||||||
|
'currency': 'GBP',
|
||||||
|
'adult': 1,
|
||||||
|
'filteredClassesOfService': ['STANDARD'],
|
||||||
|
},
|
||||||
|
'query': _GQL_PRICES,
|
||||||
|
}
|
||||||
|
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
prices: dict[str, int | None] = {}
|
||||||
|
journeys = data['data']['journeySearch']['outbound']['journeys']
|
||||||
|
for journey in journeys:
|
||||||
|
dep = journey['timing']['departureTime']
|
||||||
|
price = None
|
||||||
|
for fare in journey['fares']:
|
||||||
|
if fare['classOfService']['code'] == 'STANDARD':
|
||||||
|
p = fare.get('prices')
|
||||||
|
if p and p.get('displayPrice'):
|
||||||
|
price = int(p['displayPrice'])
|
||||||
|
break
|
||||||
|
prices[dep] = price
|
||||||
|
return prices
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %}
|
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %}
|
||||||
{% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
{% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.col-transfer { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<p style="margin-bottom:1rem">
|
<p style="margin-bottom:1rem">
|
||||||
<a href="{{ url_for('index') }}">← New search</a>
|
<a href="{{ url_for('index') }}">← New search</a>
|
||||||
|
|
@ -100,10 +105,10 @@
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
|
<th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St Pancras</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St Pancras</th>
|
||||||
<th style="padding:0.6rem 0.8rem">{{ destination }}
|
<th style="padding:0.6rem 0.8rem">{{ destination }}</th>
|
||||||
</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
|
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -138,7 +143,7 @@
|
||||||
£{{ "%.2f"|format(row.ticket_price) }}
|
£{{ "%.2f"|format(row.ticket_price) }}
|
||||||
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
|
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;color:#4a5568">
|
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568">
|
||||||
{{ row.connection_duration }}
|
{{ row.connection_duration }}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||||
|
|
@ -149,7 +154,14 @@
|
||||||
{{ row.arrive_destination }}
|
{{ row.arrive_destination }}
|
||||||
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
|
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
||||||
|
{% if row.eurostar_price is not none %}
|
||||||
|
£{{ row.eurostar_price }}
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#718096">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
|
||||||
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||||
<span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span>
|
<span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span>
|
||||||
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||||
|
|
@ -157,11 +169,14 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:#00539f">{{ row.total_duration }}</span>
|
<span style="color:#00539f">{{ row.total_duration }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if row.total_price is not none %}
|
||||||
|
<br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">—</td>
|
<td style="padding:0.6rem 0.8rem;font-weight:600">—</td>
|
||||||
<td style="padding:0.6rem 0.8rem">—</td>
|
<td style="padding:0.6rem 0.8rem">—</td>
|
||||||
<td style="padding:0.6rem 0.8rem">—</td>
|
<td class="col-transfer" style="padding:0.6rem 0.8rem">—</td>
|
||||||
<td style="padding:0.6rem 0.8rem">Unavailable</td>
|
<td style="padding:0.6rem 0.8rem">Unavailable</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||||
{{ row.depart_st_pancras }}
|
{{ row.depart_st_pancras }}
|
||||||
|
|
@ -171,8 +186,15 @@
|
||||||
{{ row.arrive_destination }}
|
{{ row.arrive_destination }}
|
||||||
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
|
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
||||||
|
{% if row.eurostar_price is not none %}
|
||||||
|
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#a0aec0">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||||
<span title="No same-day Bristol connection">Unavailable from Bristol</span>
|
<span title="No same-day Bristol connection" style="color:#a0aec0">Unavailable from Bristol</span>
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -186,7 +208,8 @@
|
||||||
{% if unreachable_morning_services %}
|
{% if unreachable_morning_services %}
|
||||||
Morning means Eurostar departures before 12:00 from St Pancras.
|
Morning means Eurostar departures before 12:00 from St Pancras.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
Eurostar times are from the general timetable and may vary; always check
|
GWR walk-on single prices for Bristol Temple Meads → Paddington.
|
||||||
|
Eurostar Standard prices are for 1 adult in GBP; always check
|
||||||
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
||||||
·
|
·
|
||||||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ def _client():
|
||||||
return app_module.app.test_client()
|
return app_module.app.test_client()
|
||||||
|
|
||||||
|
|
||||||
def _stub_data(monkeypatch):
|
def _stub_data(monkeypatch, prices=None):
|
||||||
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -33,6 +33,8 @@ def _stub_data(monkeypatch):
|
||||||
'timetable_url',
|
'timetable_url',
|
||||||
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
||||||
)
|
)
|
||||||
|
_prices = prices if prices is not None else {}
|
||||||
|
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: _prices)
|
||||||
|
|
||||||
|
|
||||||
def test_index_shows_fixed_departure_and_destination_radios():
|
def test_index_shows_fixed_departure_and_destination_radios():
|
||||||
|
|
@ -94,6 +96,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
|
||||||
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch):
|
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch):
|
||||||
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
|
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
@ -165,6 +168,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
||||||
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
|
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
@ -215,9 +219,23 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
assert html.index('09:30') < html.index('10:15')
|
assert html.index('09:30') < html.index('10:15')
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
_stub_data(monkeypatch, prices={'10:01': 59})
|
||||||
|
client = _client()
|
||||||
|
|
||||||
|
resp = client.get('/results/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 '£197.70' in html # Anytime £138.70 + ES £59
|
||||||
|
|
||||||
|
|
||||||
def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
||||||
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
|
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue