package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/rides" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseRideFilters(t *testing.T) { query := url.Values{ rideFilterDistanceMinParam: {"20"}, rideFilterDistanceMaxParam: {"100"}, rideFilterDurationMinParam: {"30"}, rideFilterDurationMaxParam: {"180"}, rideFilterDateFromParam: {"2026-08-01"}, rideFilterDateToParam: {"2026-08-31"}, rideFilterCotacolMinParam: {"50"}, rideFilterCotacolMaxParam: {"500"}, rideFilterRouteParam: {rideRouteFilterAvailable}, } filters := parseRideFilters(query) assert.Equal(t, 20, *filters.DistanceMinKm) assert.Equal(t, 100, *filters.DistanceMaxKm) assert.Equal(t, 30, *filters.DurationMinMinutes) assert.Equal(t, 180, *filters.DurationMaxMinutes) assert.Equal(t, "2026-08-01T00:00:00Z", filters.DateFrom.Format(time.RFC3339)) assert.Equal(t, "2026-09-01T00:00:00Z", filters.DateToExclusive.Format(time.RFC3339)) assert.Equal(t, 50, *filters.CotacolMinPoints) assert.Equal(t, 500, *filters.CotacolMaxPoints) assert.Equal(t, rideRouteFilterAvailable, filters.Route) assert.True(t, filters.Active()) } func TestParseRideFiltersIgnoresInvalidValues(t *testing.T) { query := url.Values{ rideFilterDistanceMinParam: {"15"}, rideFilterDistanceMaxParam: {"305"}, rideFilterDurationMinParam: {"31"}, rideFilterDurationMaxParam: {"31"}, rideFilterDateFromParam: {"2026-09-01"}, rideFilterDateToParam: {"2026-08-01"}, rideFilterCotacolMinParam: {"25"}, rideFilterCotacolMaxParam: {"25"}, rideFilterRouteParam: {"unknown"}, } filters := parseRideFilters(query) assert.False(t, filters.Active()) assert.Nil(t, filters.DistanceMinKm) assert.Nil(t, filters.DistanceMaxKm) assert.Nil(t, filters.DurationMinMinutes) assert.Nil(t, filters.DurationMaxMinutes) assert.Nil(t, filters.DateFrom) assert.Nil(t, filters.DateToExclusive) assert.Nil(t, filters.CotacolMinPoints) assert.Nil(t, filters.CotacolMaxPoints) assert.Empty(t, filters.Route) } func TestParseRideFiltersTreatsFullRangesAsInactive(t *testing.T) { filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"10"}, rideFilterDistanceMaxParam: {"300"}, rideFilterDurationMinParam: {"0"}, rideFilterDurationMaxParam: {"1440"}, rideFilterCotacolMinParam: {"0"}, rideFilterCotacolMaxParam: {"5000"}, }) assert.False(t, filters.Active()) assert.Nil(t, filters.DistanceMinKm) assert.Nil(t, filters.DistanceMaxKm) assert.Nil(t, filters.DurationMinMinutes) assert.Nil(t, filters.DurationMaxMinutes) assert.Nil(t, filters.CotacolMinPoints) assert.Nil(t, filters.CotacolMaxPoints) } func TestFilterRidesUsesInclusiveBoundsAndMovingTime(t *testing.T) { items := []rides.Ride{ {Name: "lower boundary", DistanceM: 20_000, MovingTimeS: 1_800, StartDate: time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC), GPXPath: "lower.gpx"}, {Name: "upper boundary", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "upper.gpx"}, {Name: "outside distance", DistanceM: 110_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-distance.gpx"}, {Name: "outside moving time", DistanceM: 100_000, MovingTimeS: 10_801, StartDate: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC), GPXPath: "outside-duration.gpx"}, {Name: "outside date", DistanceM: 100_000, MovingTimeS: 10_800, StartDate: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC), GPXPath: "outside-date.gpx"}, } filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"20"}, rideFilterDistanceMaxParam: {"100"}, rideFilterDurationMinParam: {"30"}, rideFilterDurationMaxParam: {"180"}, rideFilterDateFromParam: {"2026-08-01"}, rideFilterDateToParam: {"2026-08-31"}, }) filtered := filterRides(items, filters) require.Len(t, filtered, 2) assert.Equal(t, "lower boundary", filtered[0].Name) assert.Equal(t, "upper boundary", filtered[1].Name) } func TestFilterRidesByRouteAvailability(t *testing.T) { items := []rides.Ride{ {Name: "recorded", GPXPath: "recorded.gpx"}, {Name: "metadata only"}, } available := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterAvailable}})) missing := filterRides(items, parseRideFilters(url.Values{rideFilterRouteParam: {rideRouteFilterMissing}})) require.Len(t, available, 1) assert.Equal(t, "recorded", available[0].Name) require.Len(t, missing, 1) assert.Equal(t, "metadata only", missing[0].Name) } func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { _, db := newWebTestServer(t) current := rides.Ride{ ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000", GPXPath: testGPXPath(t, "current-cotacol.gpx"), Name: "Current score", Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } stale := rides.Ride{ ExternalID: "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "stale-cotacol.gpx"), Name: "Stale score", Type: "Ride", StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } unavailable := rides.Ride{ ExternalID: "strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", Name: "No score", Type: "VirtualRide", StartDate: time.Date(2026, 8, 3, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, } require.NoError(t, rides.Save(db, current)) require.NoError(t, rides.Save(db, stale)) require.NoError(t, rides.Save(db, unavailable)) _, err := db.Exec(` UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 250 WHEN ? THEN 250 END, cotacol_algo_version = CASE external_id WHEN ? THEN ? WHEN ? THEN 'old' END WHERE external_id IN (?, ?) `, current.ExternalID, stale.ExternalID, current.ExternalID, ride.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID) require.NoError(t, err) items, err := rides.List(db) require.NoError(t, err) filters := parseRideFilters(url.Values{rideFilterCotacolMinParam: {"200"}, rideFilterCotacolMaxParam: {"300"}}) filtered := filterRides(items, filters) require.Len(t, filtered, 1) assert.Equal(t, current.ExternalID, filtered[0].ExternalID) } func TestHandlerRendersRideFilterControls(t *testing.T) { server, _ := newWebTestServer(t) request := httptest.NewRequest(http.MethodGet, "/", nil) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) body := response.Body.String() assert.Equal(t, http.StatusOK, response.Code) assert.Contains(t, body, `id="ride-filters"`) assert.Contains(t, body, `name="distance_min"`) assert.Contains(t, body, `name="duration_min"`) assert.Contains(t, body, `name="date_from"`) assert.Contains(t, body, `name="cotacol_min"`) assert.Contains(t, body, `name="route"`) assert.Contains(t, body, ``) } func TestRideSortHeadersPreserveFilters(t *testing.T) { filters := parseRideFilters(url.Values{ rideFilterDistanceMinParam: {"50"}, rideFilterDistanceMaxParam: {"150"}, rideFilterRouteParam: {rideRouteFilterAvailable}, }) headers := rideSortHeadersWithFilters(RideSort{Column: rideSortDistance, Descending: true}, filters) assert.Equal(t, "/?dir=asc&distance_max=150&distance_min=50&route=available&sort=name", headers[0].URL) } func TestHandlerFiltersRidesBeforeRenderingSortedResults(t *testing.T) { server, db := newWebTestServer(t) save := func(externalID, name, path string, distance float64) { require.NoError(t, rides.Save(db, rides.Ride{ ExternalID: externalID, GPXPath: testGPXPath(t, path), Name: name, Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: distance, })) } save("strava:550e8400-e29b-41d4-a716-446655440000", "Long Ride", "long-filtered.gpx", 40_000) save("strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "Short Ride", "short-filtered.gpx", 20_000) save("strava:7ba7b810-9dad-41d1-80b4-00c04fd430c8", "Too Far Ride", "far-filtered.gpx", 320_000) request := httptest.NewRequest(http.MethodGet, "/?distance_min=10&distance_max=100&sort=distance&dir=asc", nil) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) body := response.Body.String() assert.Equal(t, http.StatusOK, response.Code) assert.Less(t, strings.Index(body, "Short Ride"), strings.Index(body, "Long Ride")) assert.NotContains(t, body, "Too Far Ride") } func TestHandlerRendersFilteredEmptyState(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, rides.Save(db, rides.Ride{ ExternalID: "strava:8ba7b810-9dad-41d1-80b4-00c04fd430c8", GPXPath: testGPXPath(t, "empty-filter.gpx"), Name: "Short Ride", Type: "Ride", StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), DistanceM: 20_000, })) request := httptest.NewRequest(http.MethodGet, "/?distance_min=300&distance_max=300", nil) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) body := response.Body.String() assert.Equal(t, http.StatusOK, response.Code) assert.Contains(t, body, "No rides match the selected filters.") assert.Contains(t, body, `href="/">Clear filters.`) assert.NotContains(t, body, "No rides stored yet.") }