Fetching every needs_review activity's laps and per-second samples up front
gets expensive once there are many -- that work is now deferred to the
current page only. GET /api/review-queue/ takes limit/before and returns
{items, next_cursor, total}; sorting/cursor filtering still needs each
activity's cheap summary row, but only the requested page's items get their
laps/samples fetched.
Frontend loads 10 at a time and fetches the next page when the list's
bottom sentinel scrolls into view. Selecting a specific-kind filter (not
"All") loads the rest of the backlog up front, since filtering only the
items scrolled into view so far would hide matches further down.
551 lines
20 KiB
Go
551 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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 page struct {
|
|
Items []map[string]any `json:"items"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
Total int `json:"total"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
|
}
|
|
laps, _ := page.Items[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, _ := page.Items[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(), &page)
|
|
if len(page.Items) != 0 || page.Total != 0 {
|
|
t.Fatalf("expected empty review queue after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
|
}
|
|
}
|
|
|
|
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
ctx := newCtx()
|
|
|
|
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
|
for i := 1; i <= 5; i++ {
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
|
GarminActivityID: int64(i), ActivityType: "running",
|
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity: %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()
|
|
|
|
type page struct {
|
|
Items []map[string]any `json:"items"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
Total int `json:"total"`
|
|
}
|
|
getPage := func(query string) page {
|
|
t.Helper()
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var p page
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
startTime := func(item map[string]any) string {
|
|
return item["activity"].(map[string]any)["StartTimeUTC"].(string)
|
|
}
|
|
|
|
first := getPage("?limit=2")
|
|
if len(first.Items) != 2 || first.Total != 5 {
|
|
t.Fatalf("first page: expected 2 items (total=5), got %d items, total=%d", len(first.Items), first.Total)
|
|
}
|
|
if startTime(first.Items[0]) != "2026-07-05 06:00:00" || startTime(first.Items[1]) != "2026-07-04 06:00:00" {
|
|
t.Fatalf("first page not newest-first: %v", first.Items)
|
|
}
|
|
if first.NextCursor == nil {
|
|
t.Fatal("expected a next_cursor on the first page")
|
|
}
|
|
|
|
second := getPage("?limit=2&before=" + url.QueryEscape(*first.NextCursor))
|
|
if len(second.Items) != 2 {
|
|
t.Fatalf("second page: expected 2 items, got %d", len(second.Items))
|
|
}
|
|
if startTime(second.Items[0]) != "2026-07-03 06:00:00" || startTime(second.Items[1]) != "2026-07-02 06:00:00" {
|
|
t.Fatalf("second page not continuing newest-first: %v", second.Items)
|
|
}
|
|
if second.NextCursor == nil {
|
|
t.Fatal("expected a next_cursor on the second page")
|
|
}
|
|
|
|
third := getPage("?limit=2&before=" + url.QueryEscape(*second.NextCursor))
|
|
if len(third.Items) != 1 {
|
|
t.Fatalf("third page: expected 1 remaining item, got %d", len(third.Items))
|
|
}
|
|
if startTime(third.Items[0]) != "2026-07-01 06:00:00" {
|
|
t.Fatalf("third page wrong item: %v", third.Items)
|
|
}
|
|
if third.NextCursor != nil {
|
|
t.Fatalf("expected no next_cursor on the last page, got %v", *third.NextCursor)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|