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>
238 lines
7.6 KiB
Go
238 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"smartrun/backend/internal/garmin/mock"
|
|
"smartrun/backend/internal/store"
|
|
appsync "smartrun/backend/internal/sync"
|
|
)
|
|
|
|
func newCtx() context.Context { return context.Background() }
|
|
|
|
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|
t.Helper()
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
m := &mock.Client{}
|
|
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
|
|
return NewServer(db, m, svc), db
|
|
}
|
|
|
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *bytes.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal request body: %v", err)
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
} else {
|
|
reader = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, path, reader)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestWorkoutKindCRUD(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
router := s.Router()
|
|
|
|
createBody := map[string]any{
|
|
"name": "Tempo",
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
|
}
|
|
rec := doJSON(t, router, http.MethodPost, "/api/workout-kinds/", createBody)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var created store.WorkoutKind
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("unmarshal created kind: %v", err)
|
|
}
|
|
if created.ID == 0 {
|
|
t.Fatal("expected non-zero id")
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list status = %d", rec.Code)
|
|
}
|
|
var kinds []store.WorkoutKind
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
if len(kinds) != 1 {
|
|
t.Fatalf("expected 1 kind, got %d", len(kinds))
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete status = %d", rec.Code)
|
|
}
|
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
if len(kinds) != 0 {
|
|
t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds))
|
|
}
|
|
}
|
|
|
|
func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{
|
|
"name": "Bad",
|
|
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
|
})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReviewQueueResolve(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
}
|
|
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
}
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
|
CandidateKindsJSON: "[]",
|
|
}); err != nil {
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
}
|
|
|
|
router := s.Router()
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("review queue status = %d", rec.Code)
|
|
}
|
|
var queue []map[string]any
|
|
json.Unmarshal(rec.Body.Bytes(), &queue)
|
|
if len(queue) != 1 {
|
|
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
json.Unmarshal(rec.Body.Bytes(), &queue)
|
|
if len(queue) != 0 {
|
|
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
|
|
}
|
|
}
|
|
|
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
|
|
|
|
speed := 3.0
|
|
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
|
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
|
|
|
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
}
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var points []progressionPoint
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(points) != 2 {
|
|
t.Fatalf("expected 2 points, got %d", len(points))
|
|
}
|
|
if points[0].Date > points[1].Date {
|
|
t.Errorf("points not sorted ascending by date: %+v", points)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|