Race is the 8th fixed workout kind, seeded with a real (not placeholder) rule since Garmin Connect's eventType.typeKey reports "race" for manually-tagged race activities. Non-running activity types (padel, cycling, strength training, ...) are now dropped at sync time instead of being stored. The Review Queue's type filter is now clickable exclusive pill buttons instead of a dropdown.
274 lines
9.0 KiB
Go
274 lines
9.0 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 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)
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|