package api import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "testing" "time" "geniusrun/backend/internal/auth" authmock "geniusrun/backend/internal/auth/mock" "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/log" "geniusrun/backend/internal/store" ) func newCtx() context.Context { return context.Background() } var testSessionConfig = SessionConfig{ Secret: []byte("test-session-secret-at-least-32-bytes-long"), Duration: time.Hour, Secure: false, BackendURL: "https://geniusrun.example.com", FrontendURL: "https://app.geniusrun.example.com", } func newTestServer(t *testing.T) (*Server, *store.DB, int64) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") if err != nil { t.Fatalf("ProvisionUser: %v", err) } m := &garmin.MockClient{} garminFactory := func(garmin.ClientConfig) garmin.Client { return m } s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig) return s, db, userID } 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") cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) if err != nil { t.Fatalf("mint test session cookie: %v", err) } req.AddCookie(cookie) 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.HRMinPctHRR != nil || k.HRMaxPctHRR != 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, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0 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, "hr_min_pct_hrr": hrMin, "hr_max_pct_hrr": hrMax, } 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.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 { t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR) } if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 { t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR) } } 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_RejectsInvertedHRRange(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":[]}`), "hr_min_pct_hrr": 80, "hr_max_pct_hrr": 70, }) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } func TestWorkoutKindUpdate_RejectsInvertedPaceRange(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":[]}`), "pace_min_sec_per_km": 420, "pace_max_sec_per_km": 330, }) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } func TestActivitiesAssign(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) if err != nil { t.Fatalf("UpsertActivity: %v", err) } kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } if _, err := db.InsertKindAssignment(ctx, userID, 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, userID, activityID, []store.Lap{ {LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh}, }); err != nil { t.Fatalf("ReplaceLaps: %v", err) } hr := 150.0 if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{ {ElapsedSeconds: 0, HeartRate: &hr}, }); err != nil { t.Fatalf("ReplaceActivitySamples: %v", err) } router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil) if rec.Code != http.StatusOK { t.Fatalf("activities list 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 activities list (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 activities list 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 activities list item, got %d: %s", len(samples), rec.Body.String()) } rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID}) if rec.Code != http.StatusOK { t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String()) } // The activity stays listed after resolving -- the Activities page shows // every activity, locked or not, not just ones still needing review. rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if len(page.Items) != 1 || page.Total != 1 { t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total) } if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"]) } if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) { t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got) } // Unlocking reverts the source to rule_engine but keeps the same kind, // so a later reclassify pass is free to change it again. rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusOK { t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) } if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) { t.Fatalf("expected WorkoutKindID=%d to survive unlock, got %v", kindID, got) } // Unlocking an already-unlocked activity is rejected. rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code) } // Manually unassigning clears the kind back to Unclassified and locks // that decision (source=manual), same as resolving to a specific kind. rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil) if rec.Code != http.StatusOK { t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"]) } if got := page.Items[0]["WorkoutKindID"]; got != nil { t.Fatalf("expected WorkoutKindID=nil after unassign, got %v", got) } // It also shows up under the Unclassified filter now. rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil) json.Unmarshal(rec.Body.Bytes(), &page) if len(page.Items) != 1 || page.Total != 1 { t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total) } // Unassigning locks it, so unlocking works again (reverting to // rule_engine sourcing with no kind, i.e. needs_review). rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil) if rec.Code != http.StatusOK { t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil) json.Unmarshal(rec.Body.Bytes(), &page) if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) } } func TestActivities_PaginatesByCursor(t *testing.T) { s, db, userID := 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, userID, store.Activity{ GarminActivityID: int64(i), 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, userID, 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/activities/"+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 TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() kinds, err := db.ListWorkoutKinds(ctx, userID, true) if err != nil || len(kinds) < 2 { t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds)) } kindA, kindB := kinds[0], kinds[1] // 2 activities assigned to kindA, 1 to kindB, 1 unclassified. makeActivity := func(n int64, kindID *int64) { activityID, err := db.UpsertActivity(ctx, userID, store.Activity{ GarminActivityID: n, StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}", }) if err != nil { t.Fatalf("UpsertActivity: %v", err) } status := store.AssignmentStatusAssigned if kindID == nil { status = store.AssignmentStatusNeedsReview } if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: status, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment: %v", err) } } makeActivity(1, &kindA.ID) makeActivity(2, &kindA.ID) makeActivity(3, &kindB.ID) makeActivity(4, nil) 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/activities/"+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 } // Filtering by kind_id, with a small limit, still paginates -- it doesn't // have to load the whole matching backlog to serve one page. kindAPage := getPage(fmt.Sprintf("?kind_id=%d&limit=1", kindA.ID)) if kindAPage.Total != 2 { t.Fatalf("kindA total = %d, want 2", kindAPage.Total) } if len(kindAPage.Items) != 1 { t.Fatalf("kindA page: expected 1 item (limit=1), got %d", len(kindAPage.Items)) } if kindAPage.NextCursor == nil { t.Fatal("expected a next_cursor on kindA's first (limited) page") } kindBPage := getPage(fmt.Sprintf("?kind_id=%d", kindB.ID)) if kindBPage.Total != 1 || len(kindBPage.Items) != 1 { t.Fatalf("kindB page: total=%d items=%d, want 1/1", kindBPage.Total, len(kindBPage.Items)) } unclassifiedPage := getPage("?unclassified=true") if unclassifiedPage.Total != 1 || len(unclassifiedPage.Items) != 1 { t.Fatalf("unclassified page: total=%d items=%d, want 1/1", unclassifiedPage.Total, len(unclassifiedPage.Items)) } if unclassifiedPage.Items[0]["WorkoutKindID"] != nil { t.Fatalf("expected nil WorkoutKindID in unclassified filter, got %v", unclassifiedPage.Items[0]["WorkoutKindID"]) } all := getPage("") if all.Total != 4 { t.Fatalf("unfiltered total = %d, want 4", all.Total) } } func TestAssignActivity_RejectsRaceKind(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) if err != nil { t.Fatalf("UpsertActivity: %v", err) } raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID}) if rec.Code != http.StatusBadRequest { t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() easyID, err := db.CreateWorkoutKind(ctx, userID, 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, userID, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) if _, err := db.InsertKindAssignment(ctx, userID, 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, userID, 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, userID, 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, userID, 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, userID, 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 TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, 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/garmin/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, userID, 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/garmin/sync/backfill", nil) if rec.Code != http.StatusNotFound { t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code) } } func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() workoutID := int64(555) activityID, err := db.UpsertActivity(ctx, userID, store.Activity{ GarminActivityID: 1, WorkoutID: &workoutID, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}", }) if err != nil { t.Fatalf("UpsertActivity: %v", err) } // ActivitiesMissingWorkout only surfaces activities whose details have // already been fetched (see store.ActivitiesMissingWorkout) -- an // activity is only eligible for a workout fetch once fillActivityDetails // has given it laps to align target pace/HR bands against. if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil { t.Fatalf("SetActivityDetails: %v", err) } rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var resp struct { InProgress bool `json:"in_progress"` Progress struct { Phase string `json:"Phase"` Done int `json:"Done"` Total int `json:"Total"` } `json:"progress"` ActivitiesPendingDetails int `json:"activities_pending_details"` WorkoutsPending int `json:"workouts_pending"` } unmarshalBody(t, rec, &resp) if resp.InProgress { t.Error("in_progress = true, want false (nothing running)") } if resp.Progress.Phase != "idle" { t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle") } if resp.ActivitiesPendingDetails != 1 { t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails) } if resp.WorkoutsPending != 1 { t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending) } } func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { s, db, userID := newTestServer(t) ctx := newCtx() kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true}) speed := 3.0 a1, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"}) a2, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, 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, userID, 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 TestMetricValue_EfficiencyFactor(t *testing.T) { speed, hr := 3.0, 150.0 v, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &hr}) if !ok { t.Fatal("expected efficiency_factor to be computable") } if v != 20 { t.Errorf("efficiency_factor = %v, want 20 (3.0/150*1000)", v) } if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed}); ok { t.Error("expected efficiency_factor to be unavailable without AvgHR") } zero := 0.0 if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &zero}); ok { t.Error("expected efficiency_factor to be unavailable with AvgHR=0") } } 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()) } } func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) { s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) var before sessionMeResponse unmarshalBody(t, rec, &before) if before.GarminConnected { t.Fatal("expected a fresh account to report garmin_connected=false") } rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess if rec.Code != http.StatusOK { t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) var after sessionMeResponse unmarshalBody(t, rec, &after) if !after.GarminConnected { t.Fatal("expected garmin_connected=true after a successful auth") } } func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) { s, _, userID := newTestServer(t) client, err := s.clientFor(context.Background(), userID) if err != nil { t.Fatalf("garminFor: %v", err) } mockClient, ok := client.(*garmin.MockClient) if !ok { t.Fatalf("expected *garmin.MockClient, got %T", client) } mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}} router := s.Router() rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) if rec.Code != http.StatusOK { t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) var me sessionMeResponse unmarshalBody(t, rec, &me) if me.GarminConnected { t.Fatal("expected garmin_connected=false after mfa_required") } } func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) { s, db, userID := newTestServer(t) router := s.Router() // Force the per-user Garmin client to be built and cached. rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) if rec.Code != http.StatusOK { t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String()) } client, err := s.clientFor(context.Background(), userID) if err != nil { t.Fatalf("garminFor: %v", err) } mockClient, ok := client.(*garmin.MockClient) if !ok { t.Fatalf("expected *garmin.MockClient, got %T", client) } rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil) if rec.Code != http.StatusNoContent { t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String()) } if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found { t.Fatalf("expected user gone after delete, found=%v err=%v", found, err) } if !mockClient.ClosedCalled { t.Error("expected the cached garmin client to be Close()d on profile deletion") } } func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) { s, db, userID := newTestServer(t) s.mu.Lock() s.userSyncRunning[userID] = true s.mu.Unlock() rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) } if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found { t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err) } } func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") if err != nil { t.Fatalf("ProvisionUser: %v", err) } tokenStoreRoot := t.TempDir() userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10)) if err := os.MkdirAll(userTokenDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } m := &garmin.MockClient{} garminFactory := func(garmin.ClientConfig) garmin.Client { return m } s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig) rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil) if rec.Code != http.StatusNoContent { t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String()) } if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) { t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err) } } func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { t.Helper() s, db, _ := newTestServer(t) s.Auth = verifier return s, db } func TestHealth_NoSessionRequired(t *testing.T) { s, _, _ := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/health", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } } func TestProtectedRoute_RejectsMissingSession(t *testing.T) { s, _, _ := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } } func TestSessionLogin_RedirectsToAuthURLAndSetsTxnCookie(t *testing.T) { s, _ := newTestServerWithAuth(t, &authmock.Verifier{ AuthURL: "https://keycloak.example.com/auth?client_id=geniusrun", Txn: auth.TxnState{State: "s1", CodeVerifier: "v1"}, }) req := httptest.NewRequest(http.MethodGet, "/api/session/login", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302", rec.Code) } if got := rec.Header().Get("Location"); got != "https://keycloak.example.com/auth?client_id=geniusrun" { t.Fatalf("Location = %q", got) } if rec.Result().Cookies()[0].Name != auth.TxnCookieName { t.Fatalf("expected a %s cookie to be set", auth.TxnCookieName) } } func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.T) { verifier := &authmock.Verifier{ CallbackResult: auth.LoginResult{ Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, Authorized: true, }, } s, _ := newTestServerWithAuth(t, verifier) txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false) if err != nil { t.Fatalf("mint txn cookie: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) req.AddCookie(txnCookie) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" { t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) } var sessionCookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { sessionCookie = c } } if sessionCookie == nil { t.Fatal("expected a session cookie to be set") } claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret) if err != nil { t.Fatalf("parse session cookie: %v", err) } if claims.Name != "Alice" { t.Fatalf("claims.Name = %q, want Alice", claims.Name) } } func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T) { verifier := &authmock.Verifier{ CallbackResult: auth.LoginResult{Claims: auth.Claims{Sub: "u1"}, Authorized: false}, } s, _ := newTestServerWithAuth(t, verifier) txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false) if err != nil { t.Fatalf("mint txn cookie: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) req.AddCookie(txnCookie) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" { t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) } for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { t.Fatal("session cookie must not be set when Authorized is false") } } } func TestSessionCallback_MissingTxnCookieRedirectsFailed(t *testing.T) { s, _ := newTestServerWithAuth(t, &authmock.Verifier{}) req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" { t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) } } func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) { rec := doJSON(t, mustServerRouter(t), http.MethodGet, "/api/session/me", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var got sessionMeResponse if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.Name != "Test User" || got.Email != "test@example.com" { t.Fatalf("got %+v, want the doJSON test-cookie identity", got) } } func mustServerRouter(t *testing.T) http.Handler { t.Helper() s, _, _ := newTestServer(t) return s.Router() } func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T) { verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout?post_logout_redirect_uri=%2F"} s, _ := newTestServerWithAuth(t, verifier) rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil) if rec.Code != http.StatusFound || rec.Header().Get("Location") != verifier.EndSessionResult { t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) } var cleared *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { cleared = c } } if cleared == nil || cleared.MaxAge >= 0 { t.Fatalf("expected session cookie to be cleared, got %+v", cleared) } } func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) { verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged s, _ := newTestServerWithAuth(t, verifier) rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil) if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" { t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/") } } // TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID // token carried in the session cookie (minted at callback time) is handed // back to EndSessionURL on logout, so Keycloak can skip its own // logout-confirmation prompt instead of leaving the user a chance to cancel // out of it after their geniusrun account is already deleted. func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) { verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"} s, _ := newTestServerWithAuth(t, verifier) cookie, err := auth.MintSessionCookie( auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure, ) if err != nil { t.Fatalf("mint session cookie: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String()) } if verifier.LastIDTokenHint != "raw-id-token-jwt" { t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt") } } // TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID // token from a completed OIDC callback ends up in the session cookie (not // just Sub/Name/Email), since that's the only place logout can later read // it back from to build id_token_hint. func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) { verifier := &authmock.Verifier{ CallbackResult: auth.LoginResult{ Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}, Authorized: true, }, } s, _ := newTestServerWithAuth(t, verifier) txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false) if err != nil { t.Fatalf("mint txn cookie: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) req.AddCookie(txnCookie) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) var sessionCookie *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == auth.SessionCookieName { sessionCookie = c } } if sessionCookie == nil { t.Fatal("expected a session cookie to be set") } claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret) if err != nil { t.Fatalf("parse session cookie: %v", err) } if claims.IDToken != "raw-id-token-jwt" { t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt") } } func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) { s, _, _ := newTestServer(t) var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil)) req := httptest.NewRequest(http.MethodGet, "/api/health", nil) req = req.WithContext(applog.WithLogger(req.Context(), logger)) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) } if entry["msg"] != "http request" { t.Errorf("msg = %v, want \"http request\"", entry["msg"]) } if entry["method"] != "GET" || entry["path"] != "/api/health" { t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"]) } if entry["status"] != float64(http.StatusOK) { t.Errorf("status = %v, want 200", entry["status"]) } if _, ok := entry["duration_ms"]; !ok { t.Error("expected a duration_ms field") } } func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) { s, db, _ := newTestServer(t) db.Close() // force a downstream DB call to fail with a 500 var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, nil)) req := httptest.NewRequest(http.MethodGet, "/api/profile", nil) req = req.WithContext(applog.WithLogger(req.Context(), logger)) cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) if err != nil { t.Fatalf("mint session cookie: %v", err) } req.AddCookie(cookie) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code) } var entry map[string]any if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) } if entry["level"] != "WARN" { t.Errorf("level = %v, want WARN for a 5xx response", entry["level"]) } }