diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 10f5163..7482715 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -185,3 +185,53 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { func itoa(v int64) string { 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()) + } +} diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go new file mode 100644 index 0000000..318d7da --- /dev/null +++ b/backend/internal/api/profile.go @@ -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) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index fcb7132..1b0a49d 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -40,6 +40,11 @@ func (s *Server) Router() http.Handler { r.Route("/api", func(r chi.Router) { 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.Post("/login", s.handleAuthLogin) r.Post("/mfa", s.handleAuthMFA) diff --git a/backend/internal/store/migrations/0003_profile.sql b/backend/internal/store/migrations/0003_profile.sql index b30798a..743a587 100644 --- a/backend/internal/store/migrations/0003_profile.sql +++ b/backend/internal/store/migrations/0003_profile.sql @@ -7,15 +7,15 @@ CREATE TABLE profile ( rolling_window_days INTEGER NOT NULL DEFAULT 90, max_heart_rate REAL, resting_heart_rate REAL, - hr_zone1_min_pct REAL NOT NULL DEFAULT 50, - hr_zone1_max_pct REAL NOT NULL DEFAULT 60, - hr_zone2_min_pct REAL NOT NULL DEFAULT 60, - hr_zone2_max_pct REAL NOT NULL DEFAULT 70, - hr_zone3_min_pct REAL NOT NULL DEFAULT 70, - hr_zone3_max_pct REAL NOT NULL DEFAULT 80, - hr_zone4_min_pct REAL NOT NULL DEFAULT 80, - hr_zone4_max_pct REAL NOT NULL DEFAULT 90, - hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone1_min_pct REAL NOT NULL DEFAULT 0, + hr_zone1_max_pct REAL NOT NULL DEFAULT 20, + hr_zone2_min_pct REAL NOT NULL DEFAULT 20, + hr_zone2_max_pct REAL NOT NULL DEFAULT 40, + hr_zone3_min_pct REAL NOT NULL DEFAULT 40, + hr_zone3_max_pct REAL NOT NULL DEFAULT 60, + hr_zone4_min_pct REAL NOT NULL DEFAULT 60, + hr_zone4_max_pct REAL NOT NULL DEFAULT 80, + hr_zone5_min_pct REAL NOT NULL DEFAULT 80, hr_zone5_max_pct REAL NOT NULL DEFAULT 100, easy_warmup_minutes REAL NOT NULL DEFAULT 10, easy_cooldown_minutes REAL NOT NULL DEFAULT 5, diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go index cd2f171..360ac3b 100644 --- a/backend/internal/store/profile_test.go +++ b/backend/internal/store/profile_test.go @@ -16,8 +16,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { if p.RollingWindowDays != 90 { t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays) } - if p.HRZone1MinPct != 50 || p.HRZone5MaxPct != 100 { - t.Errorf("zone defaults = %+v, want Z1 min=50, Z5 max=100", p) + if p.HRZone1MinPct != 0 || p.HRZone5MaxPct != 100 { + t.Errorf("zone defaults = %+v, want Z1 min=0, Z5 max=100", p) } maxHR, restingHR := 190.0, 50.0