package web
import (
"bytes"
"database/sql"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rides"
"github.com/martinlehoux/biking_home/strava"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
t.Helper()
db := dbtest.New(t)
configPath := filepath.Join(t.TempDir(), "config.yaml")
appConfig := config.Default()
appConfig.Strava.ClientID = "123"
appConfig.Strava.ClientSecret = "secret"
require.NoError(t, config.Save(configPath, appConfig))
return NewServer(db, configPath), db
}
func testGPXPath(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, []byte(`100200`), 0o600))
return path
}
type fakeSyncClient struct {
activities []strava.Activity
results map[int64]fakeSyncResult
}
type fakeSyncResult struct {
activity strava.Activity
data []byte
err error
}
func (c fakeSyncClient) List(time.Time, time.Time) ([]strava.Activity, error) {
return c.activities, nil
}
func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) {
result, ok := c.results[id]
if !ok {
return strava.Activity{}, nil, fmt.Errorf("missing fake activity %d", id)
}
return result.activity, result.data, result.err
}
const emptyTrackGPX = ``
const validTrackGPX = `100200`
const officialClimbTrackGPX = `100300100`
func TestHandlerRendersRidesPage(t *testing.T) {
server, db := newWebTestServer(t)
require.NoError(t, rides.Save(db, rides.Ride{
ExternalID: "strava:1234",
GPXPath: testGPXPath(t, "long.gpx"),
Name: "Long Ride",
Type: "Ride",
StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
DistanceM: 20_000,
}))
require.NoError(t, rides.Save(db, rides.Ride{
ExternalID: "strava:5678",
GPXPath: testGPXPath(t, "short.gpx"),
Name: "Short Ride",
Type: "Ride",
StartDate: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC),
DistanceM: 9_999,
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, response.Body.String(), "All rides")
assert.Contains(t, response.Body.String(), "Long Ride")
assert.NotContains(t, response.Body.String(), "Short Ride")
assert.Contains(t, response.Body.String(), "Cotacol")
assert.Contains(t, response.Body.String(), "Cotacol / 100 km")
assert.Contains(t, response.Body.String(), `href="/rides/1"`)
}
func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
server, db := newWebTestServer(t)
require.NoError(t, rides.Save(db, rides.Ride{
ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
GPXPath: testGPXPath(t, "detail.gpx"),
Name: "Detailed Ride",
Type: "Ride",
StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
DistanceM: 20_000,
}))
item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000")
require.NoError(t, err)
require.True(t, found)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, "Detailed Ride")
assert.Contains(t, body, `
Detailed Ride
`)
assert.NotContains(t, body, `Ride detail
`)
assert.Contains(t, body, `id="ride-route"`)
assert.Contains(t, body, `id="ride-profile"`)
assert.Contains(t, body, `id="ride-profile-chart"`)
assert.Contains(t, body, `id="ride-map"`)
assert.Contains(t, body, `class="ride-detail-grid"`)
assert.Contains(t, body, `class="ride-detail-columns`)
assert.NotContains(t, body, "Climbs to match")
assert.Contains(t, body, `class="ride-detail-main"`)
assert.Contains(t, body, ``)
assert.Contains(t, body, ``)
assert.Contains(t, body, ``)
assert.NotContains(t, body, "pointermove")
staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil)
staticResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(staticResponse, staticRequest)
script := staticResponse.Body.String()
assert.Equal(t, http.StatusOK, staticResponse.Code)
assert.Equal(t, "text/javascript; charset=utf-8", staticResponse.Header().Get("Content-Type"))
assert.Contains(t, script, "pointermove")
assert.Contains(t, script, "circleMarker")
assert.Contains(t, script, "crossing.passElevationM")
assert.Contains(t, script, "climbRoute")
assert.Contains(t, script, "L.polyline")
assert.Contains(t, script, "zoomToClimb")
assert.Contains(t, script, "Cotacol")
assert.Contains(t, script, "cotacolForClimb")
assert.Contains(t, script, "const labelY = plot.top - 6")
assert.Contains(t, body, "leaflet@1.9.4/dist/leaflet.js")
assert.Contains(t, script, "tile.openstreetmap.org/{z}/{x}/{y}.png")
assert.Contains(t, body, `"type":"FeatureCollection"`)
assert.Contains(t, body, `"type":"LineString"`)
assert.Contains(t, body, `"coordinates":[[5,43]`)
profileStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile.js", nil)
profileStaticResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(profileStaticResponse, profileStaticRequest)
profileScript := profileStaticResponse.Body.String()
assert.Equal(t, http.StatusOK, profileStaticResponse.Code)
assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type"))
assert.Contains(t, profileScript, "officialProfileSections")
assert.Contains(t, profileScript, "profileStepSizesM = [100, 200, 500, 1000]")
assert.Contains(t, profileScript, "displayStepForLength")
assert.Contains(t, profileScript, "profileBandForSlope")
assert.Contains(t, profileScript, "section.slopePercent.toFixed(1)")
}
func TestBuildRideProfileIncludesClimbsAndCrossings(t *testing.T) {
parsed := ride.FromColumns(
[]float64{0, 1000, 2000},
[]float64{300, 447, 380},
[]geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}},
make([]time.Time, 3),
)
passes := []mountain_pass.MountainPass{{
Name: "Pas de Magnan",
Elevation: 440,
Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
}}
profile := buildRideProfile(parsed, passes, nil, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Points, 3)
assert.Equal(t, 1.0, profile.Points[1].DistanceKm)
assert.Equal(t, 447.0, profile.Points[1].ElevationM)
require.Len(t, profile.Climbs, 1)
assert.Equal(t, "Pas de Magnan", profile.Climbs[0].Name)
assert.Equal(t, 1.0, profile.Climbs[0].TopKm)
assert.Empty(t, profile.OfficialClimbs)
assert.Len(t, profile.UnmatchedClimbs, 1)
require.Len(t, profile.Crossings, 1)
assert.Equal(t, "Pas de Magnan", profile.Crossings[0].Name)
assert.Equal(t, 1.0, profile.Crossings[0].DistanceKm)
}
func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) {
parsed := ride.FromColumns(
[]float64{0, 1000, 2000},
[]float64{300, 447, 380},
[]geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.63, Lon: 5.44}},
make([]time.Time, 3),
)
profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
ID: 42,
Name: "Col de Test",
StartCoord: parsed.Coord(0),
EndCoord: parsed.Coord(1),
}}, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Climbs, 1)
assert.Equal(t, int64(42), profile.Climbs[0].OfficialClimbID)
assert.Equal(t, "Col de Test", profile.Climbs[0].OfficialName)
assert.Equal(t, "Col de Test", profile.Climbs[0].Name)
assert.Len(t, profile.OfficialClimbs, 1)
assert.Empty(t, profile.UnmatchedClimbs)
}
func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) {
parsed := ride.FromColumns(
[]float64{0, 1000, 2000},
[]float64{300, 447, 380},
[]geodist.Coord{{Lat: 43.61, Lon: 5.42}, {Lat: 43.62, Lon: 5.43}, {Lat: 43.6205, Lon: 5.4305}},
make([]time.Time, 3),
)
profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
ID: 42,
Name: "Col de Test",
StartCoord: parsed.Coord(0),
EndCoord: parsed.Coord(2),
}}, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Climbs, 1)
assert.Equal(t, 0, profile.Climbs[0].StartIndex)
assert.Equal(t, 2, profile.Climbs[0].EndIndex)
assert.Equal(t, 2.0, profile.Climbs[0].EndKm)
assert.Equal(t, 2.0, profile.Climbs[0].DistanceKm)
assert.Equal(t, 4.0, profile.Climbs[0].SlopePercent)
assert.Greater(t, profile.Climbs[0].Cotacol, 0.0)
}
func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) {
server, db := newWebTestServer(t)
routePath := filepath.Join(t.TempDir(), "official-climb.gpx")
require.NoError(t, os.WriteFile(routePath, []byte(officialClimbTrackGPX), 0o600))
require.NoError(t, rides.Save(db, rides.Ride{
ExternalID: "strava:550e8400-e29b-41d4-a716-446655440000",
GPXPath: routePath,
Name: "Climb Candidate",
Type: "Ride",
StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
DistanceM: 2_000,
}))
item, found, err := rides.GetByExternalID(db, "strava:550e8400-e29b-41d4-a716-446655440000")
require.NoError(t, err)
require.True(t, found)
getRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil)
getResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(getResponse, getRequest)
assert.Equal(t, http.StatusOK, getResponse.Code)
assert.Contains(t, getResponse.Body.String(), "Waiting for an official climb match.")
assert.Contains(t, getResponse.Body.String(), "Select on profile")
assert.Contains(t, getResponse.Body.String(), "Select on map")
assert.Contains(t, getResponse.Body.String(), "Climb 1")
assert.Contains(t, getResponse.Body.String(), "data-climb-next")
form := url.Values{"name": {"Col de Test"}, "start_index": {"0"}, "end_index": {"1"}}
request := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/rides/%d/official-climbs", item.ID), strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
assert.Equal(t, http.StatusSeeOther, response.Code)
assert.Equal(t, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), response.Header().Get("Location"))
climbs, err := official_climb.List(db)
require.NoError(t, err)
require.Len(t, climbs, 1)
assert.Equal(t, "Col de Test", climbs[0].Name)
assert.Equal(t, geodist.Coord{Lat: 43.0, Lon: 5.0}, climbs[0].StartCoord)
assert.Equal(t, geodist.Coord{Lat: 43.005, Lon: 5.005}, climbs[0].EndCoord)
getRequest = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), nil)
getResponse = httptest.NewRecorder()
server.Handler().ServeHTTP(getResponse, getRequest)
body := getResponse.Body.String()
assert.Contains(t, body, "Official climb saved and matched to this ride by coordinates.")
assert.Contains(t, body, "data-official-climb-card")
assert.Contains(t, body, "Length")
assert.Contains(t, body, "Cotacol")
assert.Contains(t, body, "Avg slope")
assert.Contains(t, body, "data-official-profile")
assert.Contains(t, body, "0–3%")
assert.Contains(t, body, "12%+")
assert.NotContains(t, body, "Waiting for an official climb match.")
}
func TestHandlerRendersRideDetailWithoutRoute(t *testing.T) {
server, db := newWebTestServer(t)
item := rides.Ride{
ExternalID: "strava:14701658670",
Name: "Indoor Ride",
Type: "VirtualRide",
StartDate: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
DistanceM: 20_000,
}
require.NoError(t, rides.Save(db, item))
stored, found, err := rides.GetByExternalID(db, item.ExternalID)
require.NoError(t, err)
require.True(t, found)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", stored.ID), nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, "Indoor Ride")
assert.NotContains(t, body, "The recorded route is unavailable.")
assert.NotContains(t, body, `id="ride-map"`)
}
func TestHandlerReturnsNotFoundForUnknownRide(t *testing.T) {
server, _ := newWebTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/rides/999999", nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
assert.Equal(t, http.StatusNotFound, response.Code)
}
func TestHandlerShowsErrorForUnavailableRideRoute(t *testing.T) {
server, db := newWebTestServer(t)
_, err := db.Exec(`
INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8", "missing.gpx", "Unavailable Route", "Ride", "2026-08-01T07:00:00Z", 20_000, 0, 0, 0, 0)
require.NoError(t, err)
item, found, err := rides.GetByExternalID(db, "strava:6ba7b810-9dad-41d1-80b4-00c04fd430c8")
require.NoError(t, err)
require.True(t, found)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d", item.ID), nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, "Unavailable Route")
assert.Contains(t, body, "The recorded route is unavailable.")
assert.NotContains(t, body, `id="ride-route"`)
}
func TestSyncPageRequestsAuthorization(t *testing.T) {
server, _ := newWebTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/sync", nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, response.Body.String(), "Authorize Strava")
}
func TestSyncPageIncludesProgressUIWhenAuthorized(t *testing.T) {
server, _ := newWebTestServer(t)
appConfig, err := config.Load(server.configPath)
require.NoError(t, err)
appConfig.Strava.AccessToken = "access"
appConfig.Strava.RefreshToken = "refresh"
appConfig.Strava.ExpiresAt = time.Now().Add(time.Hour).Unix()
require.NoError(t, config.Save(server.configPath, appConfig))
req := httptest.NewRequest(http.MethodGet, "/sync", nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, req)
body := response.Body.String()
assert.Equal(t, http.StatusOK, response.Code)
assert.Contains(t, body, `id="sync-form"`)
assert.Contains(t, body, `id="sync-progress"`)
assert.Contains(t, body, `