api: scope activities, sync, review-queue, and reclassify handlers to userID
Completes the internal/api scoping pass -- the whole package now compiles against the per-user store/sync/garmin signatures from Tasks 4-13. Also fixes the test helpers (newTestServer now returns the provisioned userID) and a latent bug in TestResolveUser_LeavesContextEmptyWhenNotProvisioned, which relied on doJSON's hardcoded "test-user" session sub being unprovisioned -- never caught before since internal/api couldn't compile since Task 12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ type activityListItem struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
q := r.URL.Query()
|
||||
filter := store.ActivityFilter{
|
||||
FromDate: q.Get("from"),
|
||||
@@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
filter.Offset = offset
|
||||
}
|
||||
|
||||
activities, err := s.DB.ListActivities(r.Context(), filter)
|
||||
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), false)
|
||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
resp := make([]activityListItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
|
||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -79,13 +80,14 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), id)
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), id)
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -30,7 +30,7 @@ var testSessionConfig = SessionConfig{
|
||||
PublicBaseURL: "https://geniusrun.example.com",
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
||||
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 {
|
||||
@@ -38,14 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil {
|
||||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db
|
||||
return s, db, userID
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
@@ -73,7 +74,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
s, _ := newTestServer(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)
|
||||
@@ -81,7 +82,7 @@ func TestHealth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
||||
s, _ := newTestServer(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)
|
||||
@@ -101,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
@@ -138,7 +139,7 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
@@ -155,7 +156,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
@@ -174,7 +175,7 @@ func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||
@@ -193,31 +194,31 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReviewQueueResolve(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
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, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
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, store.KindAssignment{
|
||||
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, activityID, []store.Lap{
|
||||
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, activityID, []store.Sample{
|
||||
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
|
||||
{ElapsedSeconds: 0, HeartRate: &hr},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||
@@ -322,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
s, db := newTestServer(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, store.Activity{
|
||||
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, store.KindAssignment{
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
@@ -400,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
||||
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
||||
if err != nil || len(kinds) < 2 {
|
||||
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
||||
}
|
||||
@@ -411,7 +412,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
|
||||
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
||||
makeActivity := func(n int64, kindID *int64) {
|
||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
||||
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||
GarminActivityID: n,
|
||||
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
|
||||
})
|
||||
@@ -422,7 +423,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
if kindID == nil {
|
||||
status = store.AssignmentStatusNeedsReview
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||
Status: status, CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
@@ -487,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
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, "Race")
|
||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||
}
|
||||
@@ -506,35 +507,35 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := 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})
|
||||
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, "Race")
|
||||
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, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||
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, store.KindAssignment{
|
||||
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, store.KindAssignment{
|
||||
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, store.KindAssignment{
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
@@ -555,7 +556,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
|
||||
}
|
||||
|
||||
manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity)
|
||||
manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
||||
}
|
||||
@@ -563,7 +564,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
|
||||
}
|
||||
|
||||
raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity)
|
||||
raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
||||
}
|
||||
@@ -573,25 +574,25 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
easyID, err := db.CreateWorkoutKind(ctx, userID, 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")
|
||||
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, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||
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: "{}"})
|
||||
|
||||
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: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, 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 {
|
||||
@@ -624,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -639,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities: %v", err)
|
||||
}
|
||||
@@ -660,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
||||
kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
||||
|
||||
speed := 3.0
|
||||
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||
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, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
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)
|
||||
@@ -713,7 +714,7 @@ func itoa(v int64) string {
|
||||
}
|
||||
|
||||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
@@ -745,7 +746,7 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||
@@ -764,13 +765,13 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||
|
||||
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||
t.Helper()
|
||||
s, db := newTestServer(t)
|
||||
s, db, _ := newTestServer(t)
|
||||
s.Auth = verifier
|
||||
return s, db
|
||||
}
|
||||
|
||||
func TestHealth_NoSessionRequired(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
@@ -780,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
@@ -899,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
|
||||
|
||||
func mustServerRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
return s.Router()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
// It's a synchronous, bounded operation (unlike sync), so it runs inline
|
||||
// rather than in the background.
|
||||
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
||||
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race")
|
||||
userID := userIDFromContext(r.Context())
|
||||
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), userID, "Race")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assignments, err := s.DB.AllCurrentAssignments(r.Context())
|
||||
assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
||||
activityIDs = append(activityIDs, a.ActivityID)
|
||||
}
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, activityID := range activityIDs {
|
||||
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
|
||||
if err := svc.ClassifyActivity(r.Context(), activityID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type reviewQueueItem struct {
|
||||
// that cursor slicing too, so a filtered view still only loads (and
|
||||
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
limit := defaultReviewQueuePageSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
@@ -51,7 +52,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
||||
|
||||
queue, err := s.DB.AllCurrentAssignments(r.Context())
|
||||
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||
continue
|
||||
}
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
items := make([]reviewQueueItem, 0, len(all))
|
||||
for _, wa := range all {
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID)
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -110,7 +111,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||
// show real within-lap variation instead of one flat segment per lap
|
||||
// (most activities only have a handful of laps).
|
||||
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
|
||||
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -137,6 +138,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
@@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID)
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
kindID := body.WorkoutKindID
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
@@ -191,13 +193,14 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
// different kind), but the backend doesn't re-enforce that here, matching
|
||||
// handleResolveReview's own lack of a lock precondition check.
|
||||
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: nil,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
@@ -216,13 +219,14 @@ func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
||||
// handleResolveReview, not a delete: the kind stays visible as-is until
|
||||
// something actually reclassifies it.
|
||||
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
|
||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
||||
if current.WorkoutKindID == nil {
|
||||
status = store.AssignmentStatusNeedsReview
|
||||
}
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: current.WorkoutKindID,
|
||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s, _, _ := newTestServer(t)
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
|
||||
@@ -19,8 +19,14 @@ const detailFillBatchSize = 50
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||
return s.Sync.FullSync(ctx, detailFillBatchSize)
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
@@ -34,8 +40,14 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||
return s.Sync.ResetAll(ctx)
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
@@ -45,7 +57,8 @@ func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -54,22 +67,28 @@ func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context())
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
|
||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.syncRunning
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
progress := s.Sync.Progress()
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
s, db := newTestServer(t)
|
||||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
s, _, userID := newTestServer(t)
|
||||
|
||||
var gotUserID int64
|
||||
var gotOK bool
|
||||
@@ -31,7 +32,17 @@ func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
// Deliberately not newTestServer(t): that helper auto-provisions the
|
||||
// "test-user" sub that doJSON's cookie always carries, which would
|
||||
// defeat the point of this test. Build a server against a bare DB
|
||||
// instead, same pattern as setup_test.go's unprovisioned-session tests.
|
||||
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() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
var gotOK bool
|
||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user