feat: add profile REST endpoints with HR zone validation

Implements GET /api/profile and PUT /api/profile endpoints with validation
of HR zones (must be contiguous, non-overlapping, 0-100%). Also updates
profile migration to use correct HR zone defaults (0-20, 20-40, etc.)
that match validation requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:03:29 +02:00
parent 6f62686d29
commit 57be9065cd
5 changed files with 137 additions and 11 deletions

View File

@@ -185,3 +185,53 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
func itoa(v int64) string { func itoa(v int64) string {
return strconv.FormatInt(v, 10) return strconv.FormatInt(v, 10)
} }
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String())
}
var got store.Profile
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.RollingWindowDays != 90 {
t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays)
}
got.GarminEmail = "runner@example.com"
got.GarminPassword = "hunter2"
got.RollingWindowDays = 120
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile
json.Unmarshal(rec.Body.Bytes(), &updated)
if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 {
t.Fatalf("updated = %+v, want new email/window", updated)
}
}
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
s, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
var p store.Profile
json.Unmarshal(rec.Body.Bytes(), &p)
maxHR, restingHR := 100.0, 150.0 // resting > max: invalid
p.MaxHeartRate = &maxHR
p.RestingHeartRate = &restingHR
rec = doJSON(t, router, http.MethodPut, "/api/profile", p)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}

View File

@@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"smartrun/backend/internal/store"
)
func validateProfile(p store.Profile) error {
if p.RestingHeartRate != nil && p.MaxHeartRate != nil && *p.RestingHeartRate >= *p.MaxHeartRate {
return errors.New("resting heart rate must be less than max heart rate")
}
zones := [][2]float64{
{p.HRZone1MinPct, p.HRZone1MaxPct},
{p.HRZone2MinPct, p.HRZone2MaxPct},
{p.HRZone3MinPct, p.HRZone3MaxPct},
{p.HRZone4MinPct, p.HRZone4MaxPct},
{p.HRZone5MinPct, p.HRZone5MaxPct},
}
if zones[0][0] != 0 {
return errors.New("zone 1 must start at 0%")
}
if zones[len(zones)-1][1] != 100 {
return errors.New("zone 5 must end at 100%")
}
for i, z := range zones {
if z[0] >= z[1] {
return errors.New("each HR zone's min must be less than its max")
}
if i > 0 && z[0] != zones[i-1][1] {
return errors.New("HR zones must be contiguous and non-overlapping")
}
}
return nil
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
p, err := s.DB.GetProfile(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
var p store.Profile
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := validateProfile(p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.DB.UpdateProfile(r.Context(), p); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword)
updated, err := s.DB.GetProfile(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}

View File

@@ -40,6 +40,11 @@ func (s *Server) Router() http.Handler {
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
})
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin) r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA) r.Post("/mfa", s.handleAuthMFA)

View File

@@ -7,15 +7,15 @@ CREATE TABLE profile (
rolling_window_days INTEGER NOT NULL DEFAULT 90, rolling_window_days INTEGER NOT NULL DEFAULT 90,
max_heart_rate REAL, max_heart_rate REAL,
resting_heart_rate REAL, resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50, hr_zone1_min_pct REAL NOT NULL DEFAULT 0,
hr_zone1_max_pct REAL NOT NULL DEFAULT 60, hr_zone1_max_pct REAL NOT NULL DEFAULT 20,
hr_zone2_min_pct REAL NOT NULL DEFAULT 60, hr_zone2_min_pct REAL NOT NULL DEFAULT 20,
hr_zone2_max_pct REAL NOT NULL DEFAULT 70, hr_zone2_max_pct REAL NOT NULL DEFAULT 40,
hr_zone3_min_pct REAL NOT NULL DEFAULT 70, hr_zone3_min_pct REAL NOT NULL DEFAULT 40,
hr_zone3_max_pct REAL NOT NULL DEFAULT 80, hr_zone3_max_pct REAL NOT NULL DEFAULT 60,
hr_zone4_min_pct REAL NOT NULL DEFAULT 80, hr_zone4_min_pct REAL NOT NULL DEFAULT 60,
hr_zone4_max_pct REAL NOT NULL DEFAULT 90, hr_zone4_max_pct REAL NOT NULL DEFAULT 80,
hr_zone5_min_pct REAL NOT NULL DEFAULT 90, hr_zone5_min_pct REAL NOT NULL DEFAULT 80,
hr_zone5_max_pct REAL NOT NULL DEFAULT 100, hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
easy_warmup_minutes REAL NOT NULL DEFAULT 10, easy_warmup_minutes REAL NOT NULL DEFAULT 10,
easy_cooldown_minutes REAL NOT NULL DEFAULT 5, easy_cooldown_minutes REAL NOT NULL DEFAULT 5,

View File

@@ -16,8 +16,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.RollingWindowDays != 90 { if p.RollingWindowDays != 90 {
t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays) t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays)
} }
if p.HRZone1MinPct != 50 || p.HRZone5MaxPct != 100 { if p.HRZone1MinPct != 0 || p.HRZone5MaxPct != 100 {
t.Errorf("zone defaults = %+v, want Z1 min=50, Z5 max=100", p) t.Errorf("zone defaults = %+v, want Z1 min=0, Z5 max=100", p)
} }
maxHR, restingHR := 190.0, 50.0 maxHR, restingHR := 190.0, 50.0