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:
2026-07-25 18:28:02 +02:00
parent eaca8e602b
commit e37173ea08
7 changed files with 137 additions and 94 deletions

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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())

View File

@@ -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,

View File

@@ -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) {