From 7850f014cfd57a2a2ad213e645f787a611dfadff Mon Sep 17 00:00:00 2001 From: Jon Stelman Date: Sat, 21 Mar 2026 18:11:57 -0400 Subject: [PATCH] Add feature and range filters to GET /resorts (Issue #57) - Boolean flags: has_alpine, has_cross_country, has_night_skiing, has_terrain_parks, is_dog_friendly, has_snowshoeing, is_allied - Numeric ranges: min/max_vertical, min/max_trails, min/max_lifts, min/max_trail_length (inclusive; resorts with null values excluded) - reservation_required filter (maps to reservation_status == "Required") - Added missing fields to ResortSummary: has_night_skiing, has_terrain_parks, is_dog_friendly, has_snowshoeing, trail_length_mi - 22 new tests covering all params and composability Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 55 ++++ backend/models.py | 5 + backend/tests/test_resorts_feature_filters.py | 239 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 backend/tests/test_resorts_feature_filters.py diff --git a/backend/main.py b/backend/main.py index 488e5c3..cdae08e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -29,6 +29,24 @@ def get_resorts( region: list[str] = Query(default=[]), country: Optional[str] = Query(default=None), state: Optional[str] = Query(default=None), + # Boolean feature flags + has_alpine: Optional[bool] = Query(default=None), + has_cross_country: Optional[bool] = Query(default=None), + has_night_skiing: Optional[bool] = Query(default=None), + has_terrain_parks: Optional[bool] = Query(default=None), + is_dog_friendly: Optional[bool] = Query(default=None), + has_snowshoeing: Optional[bool] = Query(default=None), + is_allied: Optional[bool] = Query(default=None), + reservation_required: Optional[bool] = Query(default=None), + # Numeric range filters (inclusive) + min_vertical: Optional[float] = Query(default=None), + max_vertical: Optional[float] = Query(default=None), + min_trails: Optional[float] = Query(default=None), + max_trails: Optional[float] = Query(default=None), + min_lifts: Optional[float] = Query(default=None), + max_lifts: Optional[float] = Query(default=None), + min_trail_length: Optional[float] = Query(default=None), + max_trail_length: Optional[float] = Query(default=None), ): results = _resorts @@ -55,4 +73,41 @@ def get_resorts( s = state.lower() results = [r for r in results if (r.state or '').lower() == s] + # Boolean feature flags — only filter when explicitly set to True + bool_filters = [ + ('has_alpine', has_alpine), + ('has_cross_country', has_cross_country), + ('has_night_skiing', has_night_skiing), + ('has_terrain_parks', has_terrain_parks), + ('is_dog_friendly', is_dog_friendly), + ('has_snowshoeing', has_snowshoeing), + ('is_allied', is_allied), + ] + for field, value in bool_filters: + if value is not None: + results = [r for r in results if getattr(r, field) == value] + + if reservation_required is not None: + if reservation_required: + results = [r for r in results if r.reservation_status == 'Required'] + else: + results = [r for r in results if r.reservation_status != 'Required'] + + # Numeric range filters (skip resorts with no data for the field) + range_filters = [ + ('vertical', min_vertical, max_vertical), + ('num_trails', min_trails, max_trails), + ('num_lifts', min_lifts, max_lifts), + ('trail_length_mi', min_trail_length, max_trail_length), + ] + for field, lo, hi in range_filters: + if lo is not None: + results = [ + r for r in results if getattr(r, field) is not None and getattr(r, field) >= lo + ] + if hi is not None: + results = [ + r for r in results if getattr(r, field) is not None and getattr(r, field) <= hi + ] + return results diff --git a/backend/models.py b/backend/models.py index 9db6313..0d66659 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,10 +19,15 @@ class ResortSummary(BaseModel): is_allied: Optional[bool] = None has_alpine: Optional[bool] = None has_cross_country: Optional[bool] = None + has_night_skiing: Optional[bool] = None + has_terrain_parks: Optional[bool] = None + is_dog_friendly: Optional[bool] = None + has_snowshoeing: Optional[bool] = None vertical: Optional[float] = None acres: Optional[float] = None num_trails: Optional[float] = None num_lifts: Optional[float] = None + trail_length_mi: Optional[float] = None class Resort(BaseModel): diff --git a/backend/tests/test_resorts_feature_filters.py b/backend/tests/test_resorts_feature_filters.py new file mode 100644 index 0000000..bc246c0 --- /dev/null +++ b/backend/tests/test_resorts_feature_filters.py @@ -0,0 +1,239 @@ +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +from main import app +from models import ResortSummary + +FAKE_RESORTS = [ + ResortSummary( + resort_id='id-1', + name='Alpine Peak', + region='West', + city='Denver', + state='CO', + country='USA', + reservation_status='Required', + indy_page='https://example.com/alpine-peak', + has_alpine=True, + has_cross_country=False, + has_night_skiing=True, + has_terrain_parks=True, + is_dog_friendly=False, + has_snowshoeing=False, + is_allied=False, + vertical=3000.0, + num_trails=100.0, + num_lifts=15.0, + trail_length_mi=80.0, + ), + ResortSummary( + resort_id='id-2', + name='Nordic Valley', + region='Northeast', + city='Stowe', + state='VT', + country='USA', + reservation_status='Not Required', + indy_page='https://example.com/nordic-valley', + has_alpine=False, + has_cross_country=True, + has_night_skiing=False, + has_terrain_parks=False, + is_dog_friendly=True, + has_snowshoeing=True, + is_allied=True, + vertical=1200.0, + num_trails=40.0, + num_lifts=5.0, + trail_length_mi=30.0, + ), + ResortSummary( + resort_id='id-3', + name='Mid Mountain', + region='West', + city='Salt Lake City', + state='UT', + country='USA', + reservation_status='Not Required', + indy_page='https://example.com/mid-mountain', + has_alpine=True, + has_cross_country=True, + has_night_skiing=False, + has_terrain_parks=True, + is_dog_friendly=False, + has_snowshoeing=True, + is_allied=False, + vertical=2000.0, + num_trails=70.0, + num_lifts=10.0, + trail_length_mi=None, + ), +] + + +@pytest.fixture(autouse=True) +def patch_resorts(): + with patch('main._resorts', FAKE_RESORTS): + yield + + +@pytest.fixture +def client(): + return TestClient(app) + + +# --- Boolean feature flag filters --- + + +def test_filter_has_alpine_true(client): + response = client.get('/resorts?has_alpine=true') + assert response.status_code == 200 + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Mid Mountain'} + + +def test_filter_has_alpine_false(client): + response = client.get('/resorts?has_alpine=false') + assert response.status_code == 200 + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +def test_filter_has_cross_country(client): + response = client.get('/resorts?has_cross_country=true') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley', 'Mid Mountain'} + + +def test_filter_has_night_skiing(client): + response = client.get('/resorts?has_night_skiing=true') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak'} + + +def test_filter_has_terrain_parks(client): + response = client.get('/resorts?has_terrain_parks=true') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Mid Mountain'} + + +def test_filter_is_dog_friendly(client): + response = client.get('/resorts?is_dog_friendly=true') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +def test_filter_has_snowshoeing(client): + response = client.get('/resorts?has_snowshoeing=true') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley', 'Mid Mountain'} + + +def test_filter_is_allied(client): + response = client.get('/resorts?is_allied=true') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +# --- reservation_required --- + + +def test_reservation_required_true(client): + response = client.get('/resorts?reservation_required=true') + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'Alpine Peak' + + +def test_reservation_required_false(client): + response = client.get('/resorts?reservation_required=false') + assert response.status_code == 200 + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley', 'Mid Mountain'} + + +# --- Numeric range filters --- + + +def test_min_vertical(client): + response = client.get('/resorts?min_vertical=2000') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Mid Mountain'} + + +def test_max_vertical(client): + response = client.get('/resorts?max_vertical=2000') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley', 'Mid Mountain'} + + +def test_vertical_range(client): + response = client.get('/resorts?min_vertical=1500&max_vertical=2500') + names = {r['name'] for r in response.json()} + assert names == {'Mid Mountain'} + + +def test_min_trails(client): + response = client.get('/resorts?min_trails=70') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Mid Mountain'} + + +def test_max_trails(client): + response = client.get('/resorts?max_trails=40') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +def test_min_lifts(client): + response = client.get('/resorts?min_lifts=10') + names = {r['name'] for r in response.json()} + assert names == {'Alpine Peak', 'Mid Mountain'} + + +def test_max_lifts(client): + response = client.get('/resorts?max_lifts=5') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +def test_min_trail_length(client): + response = client.get('/resorts?min_trail_length=50') + names = {r['name'] for r in response.json()} + # Mid Mountain has None trail_length_mi — excluded + assert names == {'Alpine Peak'} + + +def test_max_trail_length(client): + response = client.get('/resorts?max_trail_length=30') + names = {r['name'] for r in response.json()} + assert names == {'Nordic Valley'} + + +def test_range_excludes_null_values(client): + # Mid Mountain has no trail_length_mi — should be excluded from range filter + response = client.get('/resorts?min_trail_length=1') + names = {r['name'] for r in response.json()} + assert 'Mid Mountain' not in names + + +# --- Composability --- + + +def test_bool_and_range_combined(client): + response = client.get('/resorts?has_alpine=true&max_vertical=2500') + names = {r['name'] for r in response.json()} + assert names == {'Mid Mountain'} + + +def test_bool_and_reservation_combined(client): + response = client.get('/resorts?has_alpine=true&reservation_required=false') + names = {r['name'] for r in response.json()} + assert names == {'Mid Mountain'}