diff --git a/tests/test_busy.py b/tests/test_busy.py
index 8a12d8c..d3b80fc 100644
--- a/tests/test_busy.py
+++ b/tests/test_busy.py
@@ -1,4 +1,4 @@
-from datetime import date, datetime
+from datetime import date, datetime, timezone
import agenda.busy
import agenda.travel as travel
@@ -157,7 +157,8 @@ def test_parse_datetime_field():
# Test with datetime object
dt = datetime(2023, 1, 1, 12, 0, 0)
parsed_dt, parsed_date = _parse_datetime_field(dt)
- assert parsed_dt == dt
+ assert parsed_dt == dt.replace(tzinfo=timezone.utc)
+ assert parsed_dt.tzinfo == timezone.utc
assert parsed_date == date(2023, 1, 1)
# Test with ISO string
diff --git a/tests/test_busy_timezone.py b/tests/test_busy_timezone.py
new file mode 100644
index 0000000..73d35dd
--- /dev/null
+++ b/tests/test_busy_timezone.py
@@ -0,0 +1,35 @@
+"""Regression tests for timezone handling in busy location logic."""
+
+from datetime import date, datetime, timezone
+
+import agenda.busy
+from agenda.types import Trip
+
+
+def test_mixed_naive_and_aware_arrivals_do_not_crash() -> None:
+ """Most recent travel should compare mixed timezone styles safely."""
+ trips = [
+ Trip(
+ start=date(2099, 12, 30),
+ travel=[
+ {
+ "type": "flight",
+ "arrive": datetime(2100, 1, 1, 10, 0, 0),
+ "to": "CDG",
+ "to_airport": {"country": "fr", "city": "Paris"},
+ },
+ {
+ "type": "flight",
+ "arrive": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ "to": "AMS",
+ "to_airport": {"country": "nl", "city": "Amsterdam"},
+ },
+ ],
+ )
+ ]
+
+ location = agenda.busy._find_most_recent_travel_before_date(date(2100, 1, 1), trips)
+ assert location is not None
+ assert location[0] == "Amsterdam"
+ assert location[1] is not None
+ assert location[1].alpha_2 == "NL"
diff --git a/tests/test_trip.py b/tests/test_trip.py
new file mode 100644
index 0000000..69c0e6e
--- /dev/null
+++ b/tests/test_trip.py
@@ -0,0 +1,103 @@
+"""Tests for trip map coordinate assembly."""
+
+from datetime import date
+
+import agenda.trip
+from agenda.types import Trip
+from web_view import app
+
+
+def test_add_coordinates_for_unbooked_flights_adds_missing_airports() -> None:
+ """Unbooked routes should contribute missing airport pins."""
+ routes = [
+ {
+ "type": "unbooked_flight",
+ "key": "LHR_Paris_fr",
+ "from_iata": "LHR",
+ "to_iata": "CDG",
+ "from": (51.47, -0.45),
+ "to": (49.01, 2.55),
+ }
+ ]
+ coordinates = [
+ {
+ "name": "Heathrow Airport",
+ "type": "airport",
+ "latitude": 51.47,
+ "longitude": -0.45,
+ }
+ ]
+ airports = {
+ "LHR": {
+ "name": "Heathrow Airport",
+ "latitude": 51.47,
+ "longitude": -0.45,
+ },
+ "CDG": {
+ "name": "Paris Charles de Gaulle Airport",
+ "latitude": 49.01,
+ "longitude": 2.55,
+ },
+ }
+
+ with app.app_context():
+ original_parse_yaml = agenda.trip.travel.parse_yaml
+ try:
+ agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports
+ agenda.trip.add_coordinates_for_unbooked_flights(
+ routes, coordinates, app.config["PERSONAL_DATA"]
+ )
+ finally:
+ agenda.trip.travel.parse_yaml = original_parse_yaml
+
+ airport_names = {
+ coord["name"] for coord in coordinates if coord["type"] == "airport"
+ }
+ assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"}
+
+
+def test_get_coordinates_and_routes_adds_unbooked_flight_airports() -> None:
+ """Trip list map data should include pins for unbooked flights."""
+ trips = [Trip(start=date(2026, 7, 20))]
+ unbooked_routes = [
+ {
+ "type": "unbooked_flight",
+ "key": "LHR_Paris_fr",
+ "from_iata": "LHR",
+ "to_iata": "CDG",
+ "from": (51.47, -0.45),
+ "to": (49.01, 2.55),
+ }
+ ]
+ airports = {
+ "LHR": {
+ "name": "Heathrow Airport",
+ "latitude": 51.47,
+ "longitude": -0.45,
+ },
+ "CDG": {
+ "name": "Paris Charles de Gaulle Airport",
+ "latitude": 49.01,
+ "longitude": 2.55,
+ },
+ }
+
+ with app.app_context():
+ original_collect_trip_coordinates = agenda.trip.collect_trip_coordinates
+ original_get_trip_routes = agenda.trip.get_trip_routes
+ original_parse_yaml = agenda.trip.travel.parse_yaml
+ try:
+ agenda.trip.collect_trip_coordinates = lambda _trip: []
+ agenda.trip.get_trip_routes = lambda _trip, _data_dir: unbooked_routes
+ agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports
+
+ coordinates, _routes = agenda.trip.get_coordinates_and_routes(trips)
+ finally:
+ agenda.trip.collect_trip_coordinates = original_collect_trip_coordinates
+ agenda.trip.get_trip_routes = original_get_trip_routes
+ agenda.trip.travel.parse_yaml = original_parse_yaml
+
+ airport_names = {
+ coord["name"] for coord in coordinates if coord["type"] == "airport"
+ }
+ assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"}
diff --git a/tests/test_trip_page_route.py b/tests/test_trip_page_route.py
new file mode 100644
index 0000000..d471caa
--- /dev/null
+++ b/tests/test_trip_page_route.py
@@ -0,0 +1,131 @@
+"""Regression tests for trip page route wiring and rendering."""
+
+from datetime import date
+import typing
+
+import web_view
+from agenda.types import Trip
+
+
+def test_trip_page_passes_data_dir_to_unbooked_flight_helper() -> None:
+ """Trip page should call helper with routes, coordinates and data_dir."""
+ trip = Trip(start=date(2025, 1, 28))
+ captured: dict[str, str] = {}
+
+ with web_view.app.app_context():
+ original_get_trip_list = web_view.get_trip_list
+ original_add_schengen = (
+ web_view.agenda.trip_schengen.add_schengen_compliance_to_trip
+ )
+ original_collect_trip_coordinates = (
+ web_view.agenda.trip.collect_trip_coordinates
+ )
+ original_get_trip_routes = web_view.agenda.trip.get_trip_routes
+ original_add_coordinates = (
+ web_view.agenda.trip.add_coordinates_for_unbooked_flights
+ )
+ original_get_trip_weather = web_view.agenda.weather.get_trip_weather
+ original_render_template = web_view.flask.render_template
+ try:
+ web_view.get_trip_list = lambda: [trip]
+ web_view.agenda.trip_schengen.add_schengen_compliance_to_trip = lambda t: t
+ web_view.agenda.trip.collect_trip_coordinates = lambda _trip: []
+ web_view.agenda.trip.get_trip_routes = lambda _trip, _data_dir: []
+
+ def fake_add_coordinates(
+ _routes: list[typing.Any],
+ _coordinates: list[typing.Any],
+ data_dir: str,
+ ) -> None:
+ captured["data_dir"] = data_dir
+
+ web_view.agenda.trip.add_coordinates_for_unbooked_flights = (
+ fake_add_coordinates
+ )
+ web_view.agenda.weather.get_trip_weather = lambda *_args, **_kwargs: []
+ web_view.flask.render_template = lambda *_args, **_kwargs: "ok"
+
+ with web_view.app.test_request_context("/trip/2025-01-28"):
+ result = web_view.trip_page("2025-01-28")
+
+ assert result == "ok"
+ assert captured["data_dir"] == web_view.app.config["PERSONAL_DATA"]
+ finally:
+ web_view.get_trip_list = original_get_trip_list
+ web_view.agenda.trip_schengen.add_schengen_compliance_to_trip = (
+ original_add_schengen
+ )
+ web_view.agenda.trip.collect_trip_coordinates = (
+ original_collect_trip_coordinates
+ )
+ web_view.agenda.trip.get_trip_routes = original_get_trip_routes
+ web_view.agenda.trip.add_coordinates_for_unbooked_flights = (
+ original_add_coordinates
+ )
+ web_view.agenda.weather.get_trip_weather = original_get_trip_weather
+ web_view.flask.render_template = original_render_template
+
+
+def test_trip_page_renders_with_date_only_train_leg() -> None:
+ """Trip page should render when train legs use date values (no time)."""
+ trip = Trip(
+ start=date(2025, 1, 28),
+ travel=[
+ {
+ "type": "train",
+ "depart": date(2025, 1, 28),
+ "from": "A",
+ "to": "B",
+ "from_station": {
+ "name": "A",
+ "country": "gb",
+ "latitude": 51.5,
+ "longitude": -0.1,
+ },
+ "to_station": {
+ "name": "B",
+ "country": "gb",
+ "latitude": 51.6,
+ "longitude": -0.2,
+ },
+ "legs": [
+ {
+ "from": "A",
+ "to": "B",
+ "depart": date(2025, 1, 28),
+ "arrive": date(2025, 1, 28),
+ "from_station": {
+ "name": "A",
+ "country": "gb",
+ "latitude": 51.5,
+ "longitude": -0.1,
+ },
+ "to_station": {
+ "name": "B",
+ "country": "gb",
+ "latitude": 51.6,
+ "longitude": -0.2,
+ },
+ "operator": "Test Rail",
+ }
+ ],
+ }
+ ],
+ )
+
+ with web_view.app.app_context():
+ original_get_trip_list = web_view.get_trip_list
+ original_get_trip_weather = web_view.agenda.weather.get_trip_weather
+ try:
+ web_view.get_trip_list = lambda: [trip]
+ web_view.agenda.weather.get_trip_weather = lambda *_args, **_kwargs: []
+ web_view.app.config["TESTING"] = True
+
+ with web_view.app.test_client() as client:
+ response = client.get("/trip/2025-01-28")
+
+ assert response.status_code == 200
+ assert b"Test Rail" in response.data
+ finally:
+ web_view.get_trip_list = original_get_trip_list
+ web_view.agenda.weather.get_trip_weather = original_get_trip_weather
diff --git a/tests/test_weekends_route.py b/tests/test_weekends_route.py
new file mode 100644
index 0000000..46de40c
--- /dev/null
+++ b/tests/test_weekends_route.py
@@ -0,0 +1,31 @@
+"""Tests for weekends route query validation."""
+
+from datetime import date
+import typing
+
+import pytest
+
+import web_view
+
+
+@pytest.fixture # type: ignore[untyped-decorator]
+def client() -> typing.Any:
+ """Flask test client."""
+ web_view.app.config["TESTING"] = True
+ with web_view.app.test_client() as c:
+ yield c
+
+
+def test_weekends_rejects_year_before_2020(client: typing.Any) -> None:
+ """Years before 2020 should return HTTP 400."""
+ response = client.get("/weekends?year=2019&week=1")
+ assert response.status_code == 400
+ assert b"Year must be between 2020" in response.data
+
+
+def test_weekends_rejects_year_more_than_five_years_ahead(client: typing.Any) -> None:
+ """Years beyond current year + 5 should return HTTP 400."""
+ too_far = date.today().year + 6
+ response = client.get(f"/weekends?year={too_far}&week=1")
+ assert response.status_code == 400
+ assert b"Year must be between 2020" in response.data
diff --git a/update.py b/update.py
index 97e4d41..f6e5ef3 100755
--- a/update.py
+++ b/update.py
@@ -321,6 +321,9 @@ def update_thespacedevs(config: flask.config.Config) -> None:
existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir)
assert existing_data
+ if agenda.thespacedevs.is_launches_cache_fresh(rocket_dir):
+ return
+
# Update active crewed mission cache used by the launches page.
# Uses the 2-hour TTL; failures are handled internally with cache fallback.
active_crewed = agenda.thespacedevs.get_active_crewed_flights(rocket_dir)
diff --git a/web_view.py b/web_view.py
index 4fa2d7c..18a62dc 100755
--- a/web_view.py
+++ b/web_view.py
@@ -268,6 +268,16 @@ async def gaps_page() -> str:
async def weekends() -> str:
"""List of available weekends using an optional date, week, or year parameter."""
today = datetime.now().date()
+ min_year = 2020
+ max_year = today.year + 5
+
+ def validate_year(year: int) -> None:
+ """Validate year parameter range for weekends page."""
+ if year < min_year or year > max_year:
+ flask.abort(
+ 400, description=f"Year must be between {min_year} and {max_year}."
+ )
+
date_str = flask.request.args.get("date")
week_str = flask.request.args.get("week")
year_str = flask.request.args.get("year")
@@ -275,12 +285,14 @@ async def weekends() -> str:
if date_str:
try:
start = datetime.strptime(date_str, "%Y-%m-%d").date()
+ validate_year(start.year)
except ValueError:
return flask.abort(400, description="Invalid date format. Use YYYY-MM-DD.")
elif week_str:
try:
week = int(week_str)
year = int(year_str) if year_str else today.year
+ validate_year(year)
if week < 1 or week > 53:
return flask.abort(
400, description="Week number must be between 1 and 53."
@@ -293,6 +305,13 @@ async def weekends() -> str:
return flask.abort(
400, description="Invalid week or year format. Use integers."
)
+ elif year_str:
+ try:
+ year = int(year_str)
+ validate_year(year)
+ start = date(year, 1, 1)
+ except ValueError:
+ return flask.abort(400, description="Invalid year format. Use an integer.")
else:
start = date(today.year, 1, 1)
@@ -914,9 +933,7 @@ def get_destination_timezones(trip: Trip) -> list[StrDict]:
if flight_country:
flight_locations.append((city, flight_country))
- existing_location_keys = {
- (loc, c.alpha_2.lower()) for loc, c in trip.locations()
- }
+ existing_location_keys = {(loc, c.alpha_2.lower()) for loc, c in trip.locations()}
all_locations = list(trip.locations()) + [
(city, country)
for city, country in flight_locations
@@ -1018,7 +1035,9 @@ def trip_page(start: str) -> str:
coordinates = agenda.trip.collect_trip_coordinates(trip)
routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"])
- agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates)
+ agenda.trip.add_coordinates_for_unbooked_flights(
+ routes, coordinates, app.config["PERSONAL_DATA"]
+ )
for route in routes:
if "geojson_filename" in route: