diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go index 1527673..defc327 100644 --- a/backend/internal/api/activities.go +++ b/backend/internal/api/activities.go @@ -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 diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 63b1d53..a5cbd8b 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -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() } diff --git a/backend/internal/api/reclassify.go b/backend/internal/api/reclassify.go index 1ece10a..a5dafe8 100644 --- a/backend/internal/api/reclassify.go +++ b/backend/internal/api/reclassify.go @@ -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 } diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go index 467a617..4928809 100644 --- a/backend/internal/api/review.go +++ b/backend/internal/api/review.go @@ -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, diff --git a/backend/internal/api/setup_test.go b/backend/internal/api/setup_test.go index 8b0d767..f8ddf0d 100644 --- a/backend/internal/api/setup_test.go +++ b/backend/internal/api/setup_test.go @@ -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()) diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go index 63f26d0..6a9e5f5 100644 --- a/backend/internal/api/sync.go +++ b/backend/internal/api/sync.go @@ -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, diff --git a/backend/internal/api/usercontext_test.go b/backend/internal/api/usercontext_test.go index 8513d77..595a800 100644 --- a/backend/internal/api/usercontext_test.go +++ b/backend/internal/api/usercontext_test.go @@ -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) {