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 TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) { s, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } var kinds []workoutKindResponse if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil { t.Fatalf("unmarshal: %v", err) } if len(kinds) != 8 { t.Fatalf("expected 8 seeded kinds, got %d", len(kinds)) } for _, k := range kinds { if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil { t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k) } } } func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) target := kinds[0] minPace, maxPace, zone := 330.0, 420.0, 2 body := map[string]any{ "name": target.Name, "rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`), "pace_min_sec_per_km": minPace, "pace_max_sec_per_km": maxPace, "expected_hr_zone": zone, } rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body) if rec.Code != http.StatusOK { t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil) var updated workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &updated) if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 { t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm) } if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 { t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone) } } func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ "name": kinds[0].Name, "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 TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ "name": kinds[0].Name, "rule": json.RawMessage(`{"match":"all","conditions":[]}`), "expected_hr_zone": 9, }) 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) } targetLow, targetHigh := 3.0, 3.5 if err := db.ReplaceLaps(ctx, activityID, []store.Lap{ {LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh}, }); err != nil { t.Fatalf("ReplaceLaps: %v", err) } hr := 150.0 if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{ {ElapsedSeconds: 0, HeartRate: &hr}, }); err != nil { t.Fatalf("ReplaceActivitySamples: %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()) } laps, _ := queue[0]["laps"].([]any) if len(laps) != 1 { t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String()) } samples, _ := queue[0]["samples"].([]any) if len(samples) != 1 { t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), 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 TestResolveReview_RejectsRaceKind(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) } raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID}) if rec.Code != http.StatusBadRequest { t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment (rule engine): %v", err) } if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment (manual): %v", err) } if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment (race): %v", err) } rec := doJSON(t, s.Router(), http.MethodPost, "/api/reclassify", nil) if rec.Code != http.StatusOK { t.Fatalf("reclassify status = %d, body = %s", rec.Code, rec.Body.String()) } var body struct { Reclassified int `json:"reclassified"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal: %v", err) } if body.Reclassified != 1 { t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified) } manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity) if err != nil || !ok { t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err) } if manualAssignment.AssignmentSource != store.AssignmentSourceManual { t.Errorf("manual assignment was overwritten: %+v", manualAssignment) } raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity) if err != nil || !ok { t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err) } if raceAssignment.WorkoutKindID == nil || *raceAssignment.WorkoutKindID != raceKind.ID { t.Errorf("race assignment changed: %+v", raceAssignment) } } func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var items []activityListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("unmarshal: %v", err) } locked := map[int64]bool{} kindName := map[int64]string{} for _, it := range items { locked[it.ID] = it.Locked if it.WorkoutKindName != nil { kindName[it.ID] = *it.WorkoutKindName } } if locked[ruleEngineActivity] { t.Errorf("rule-engine activity should not be locked") } if !locked[manualActivity] { t.Errorf("manual activity should be locked") } if !locked[raceActivity] { t.Errorf("race activity should be locked") } if kindName[raceActivity] != "Race" { t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race") } } func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { t.Fatalf("UpsertActivity: %v", err) } router := s.Router() rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil) if rec.Code != http.StatusAccepted { t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String()) } deadline := time.Now().Add(2 * time.Second) for { activities, err := db.ListActivities(ctx, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) == 0 { break } if time.Now().After(deadline) { t.Fatalf("timed out waiting for reset to delete activities, still have %d", len(activities)) } time.Sleep(10 * time.Millisecond) } // "Full backfill" no longer exists as an endpoint -- superseded by reset. rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil) if rec.Code != http.StatusNotFound { t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code) } } 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()) } }