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/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/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
_, err = db.Exec(`
create table rides (
id integer primary key,
external_id text unique not null,
gpx_path text not null,
name text not null,
type text not null,
start_date text not null,
distance_m real not null,
moving_time_s integer not null,
elapsed_time_s integer not null,
total_elevation_gain_m real not null,
average_speed_mps real not null,
cotacol_score real,
cotacol_algo_version text,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`)
require.NoError(t, err)
_, err = db.Exec(`
create table mountain_passes (
id integer primary key,
external_id text unique not null,
name text not null,
country_code text not null,
department_code text not null,
elevation integer not null,
latitude real,
longitude real
)
`)
require.NoError(t, err)
_, err = db.Exec(`
create table official_climbs (
id integer primary key,
name text not null,
start_latitude real not null,
start_longitude real not null,
end_latitude real not null,
end_longitude real not null,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)
`)
require.NoError(t, err)
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(`
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-sidebar"`) assert.Contains(t, body, `class="ride-detail-main"`) 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]`) } 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) 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) } 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(), "No official climb matched yet.") 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) assert.Contains(t, getResponse.Body.String(), "Official climb saved and matched to this ride by coordinates.") assert.Contains(t, getResponse.Body.String(), "Official climb matched: Col de Test") } 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, `