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) { func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
q := r.URL.Query() q := r.URL.Query()
filter := store.ActivityFilter{ filter := store.ActivityFilter{
FromDate: q.Get("from"), FromDate: q.Get("from"),
@@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
filter.Offset = offset filter.Offset = offset
} }
activities, err := s.DB.ListActivities(r.Context(), filter) activities, err := s.DB.ListActivities(r.Context(), userID, filter)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
kinds, err := s.DB.ListWorkoutKinds(r.Context(), false) kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
resp := make([]activityListItem, 0, len(activities)) resp := make([]activityListItem, 0, len(activities))
for _, a := range activities { for _, a := range activities {
item := activityListItem{activityResponse: toActivityResponse(a)} 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id") writeError(w, http.StatusBadRequest, "invalid activity id")
return return
} }
activity, ok, err := s.DB.GetActivity(r.Context(), id) activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
return return
} }
laps, err := s.DB.LapsForActivity(r.Context(), id) laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id) assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return

View File

@@ -30,7 +30,7 @@ var testSessionConfig = SessionConfig{
PublicBaseURL: "https://geniusrun.example.com", PublicBaseURL: "https://geniusrun.example.com",
} }
func newTestServer(t *testing.T) (*Server, *store.DB) { func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Helper() t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
@@ -38,14 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
} }
t.Cleanup(func() { db.Close() }) 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) t.Fatalf("ProvisionUser: %v", err)
} }
m := &mock.Client{} m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) 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 { 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) { func TestHealth(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil) rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code) t.Fatalf("status = %d, want 200", rec.Code)
@@ -81,7 +82,7 @@ func TestHealth(t *testing.T) {
} }
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(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) rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code) t.Fatalf("status = %d", rec.Code)
@@ -101,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
} }
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) 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) { func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) 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) { func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) 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) { func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) 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) { func TestReviewQueueResolve(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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 { if err != nil {
t.Fatalf("UpsertActivity: %v", err) 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 { if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err) 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, ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]", CandidateKindsJSON: "[]",
}); err != nil { }); err != nil {
t.Fatalf("InsertKindAssignment: %v", err) t.Fatalf("InsertKindAssignment: %v", err)
} }
targetLow, targetHigh := 3.0, 3.5 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}, {LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
}); err != nil { }); err != nil {
t.Fatalf("ReplaceLaps: %v", err) t.Fatalf("ReplaceLaps: %v", err)
} }
hr := 150.0 hr := 150.0
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{ if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
{ElapsedSeconds: 0, HeartRate: &hr}, {ElapsedSeconds: 0, HeartRate: &hr},
}); err != nil { }); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err) t.Fatalf("ReplaceActivitySamples: %v", err)
@@ -322,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) {
} }
func TestReviewQueue_PaginatesByCursor(t *testing.T) { func TestReviewQueue_PaginatesByCursor(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01. // 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
for i := 1; i <= 5; i++ { for i := 1; i <= 5; i++ {
activityID, err := db.UpsertActivity(ctx, store.Activity{ activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: int64(i), GarminActivityID: int64(i),
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}", StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
}) })
if err != nil { if err != nil {
t.Fatalf("UpsertActivity: %v", err) 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, ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]", CandidateKindsJSON: "[]",
}); err != nil { }); err != nil {
@@ -400,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
} }
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
kinds, err := db.ListWorkoutKinds(ctx, true) kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil || len(kinds) < 2 { if err != nil || len(kinds) < 2 {
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds)) 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. // 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
makeActivity := func(n int64, kindID *int64) { makeActivity := func(n int64, kindID *int64) {
activityID, err := db.UpsertActivity(ctx, store.Activity{ activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: n, GarminActivityID: n,
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}", 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 { if kindID == nil {
status = store.AssignmentStatusNeedsReview 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, ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status, CandidateKindsJSON: "[]", Status: status, CandidateKindsJSON: "[]",
}); err != nil { }); err != nil {
@@ -487,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
} }
func TestResolveReview_RejectsRaceKind(t *testing.T) { func TestResolveReview_RejectsRaceKind(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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 { if err != nil {
t.Fatalf("UpsertActivity: %v", err) t.Fatalf("UpsertActivity: %v", err)
} }
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) 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) { func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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 { if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err) t.Fatalf("CreateWorkoutKind: %v", err)
} }
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) 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: "{}"}) ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"}) manualActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"}) 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, ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil { }); err != nil {
t.Fatalf("InsertKindAssignment (rule engine): %v", err) 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, ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil { }); err != nil {
t.Fatalf("InsertKindAssignment (manual): %v", err) 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, ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil { }); 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) 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 { if err != nil || !ok {
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err) 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) 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 { if err != nil || !ok {
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err) 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) { func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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 { if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err) t.Fatalf("CreateWorkoutKind: %v", err)
} }
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) 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: "{}"}) ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"}) manualActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"}) 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, userID, 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, userID, 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: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -624,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
} }
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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) t.Fatalf("UpsertActivity: %v", err)
} }
@@ -639,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for { for {
activities, err := db.ListActivities(ctx, store.ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -660,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
} }
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() 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 speed := 3.0
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 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, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 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 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) 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) { func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) 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) { func TestProfile_RejectsInvalidHRZones(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) 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) { func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper() t.Helper()
s, db := newTestServer(t) s, db, _ := newTestServer(t)
s.Auth = verifier s.Auth = verifier
return s, db return s, db
} }
func TestHealth_NoSessionRequired(t *testing.T) { func TestHealth_NoSessionRequired(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil) req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) s.Router().ServeHTTP(rec, req)
@@ -780,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) {
} }
func TestProtectedRoute_RejectsMissingSession(t *testing.T) { func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil) req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) s.Router().ServeHTTP(rec, req)
@@ -899,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
func mustServerRouter(t *testing.T) http.Handler { func mustServerRouter(t *testing.T) http.Handler {
t.Helper() t.Helper()
s, _ := newTestServer(t) s, _, _ := newTestServer(t)
return s.Router() return s.Router()
} }

View File

@@ -18,13 +18,14 @@ import (
// It's a synchronous, bounded operation (unlike sync), so it runs inline // It's a synchronous, bounded operation (unlike sync), so it runs inline
// rather than in the background. // rather than in the background.
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
assignments, err := s.DB.AllCurrentAssignments(r.Context()) assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
activityIDs = append(activityIDs, a.ActivityID) 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 { 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }

View File

@@ -35,6 +35,7 @@ type reviewQueueItem struct {
// that cursor slicing too, so a filtered view still only loads (and // 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. // chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
limit := defaultReviewQueuePageSize limit := defaultReviewQueuePageSize
if v := r.URL.Query().Get("limit"); v != "" { if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 { 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" 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
if unclassifiedOnly && a.WorkoutKindID != nil { if unclassifiedOnly && a.WorkoutKindID != nil {
continue 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
items := make([]reviewQueueItem, 0, len(all)) items := make([]reviewQueueItem, 0, len(all))
for _, wa := range 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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 // Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap // show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps). // (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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id") writeError(w, http.StatusBadRequest, "invalid activity id")
@@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
} }
kindID := body.WorkoutKindID kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: &kindID, WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual, 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 // different kind), but the backend doesn't re-enforce that here, matching
// handleResolveReview's own lack of a lock precondition check. // handleResolveReview's own lack of a lock precondition check.
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id") writeError(w, http.StatusBadRequest, "invalid activity id")
return return
} }
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: nil, WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual, 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 // handleResolveReview, not a delete: the kind stays visible as-is until
// something actually reclassifies it. // something actually reclassifies it.
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id") writeError(w, http.StatusBadRequest, "invalid activity id")
return return
} }
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID) current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
if current.WorkoutKindID == nil { if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview status = store.AssignmentStatusNeedsReview
} }
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID, WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine, AssignmentSource: store.AssignmentSourceRuleEngine,

View File

@@ -70,7 +70,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
} }
func TestSetup_RejectsWhenAlreadyProvisioned(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"}) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict { if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) 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 // FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last. // whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error { userID := userIDFromContext(r.Context())
return s.Sync.FullSync(ctx, detailFillBatchSize) 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 { if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress") 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 // performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation. // gates this behind a confirmation.
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error { userID := userIDFromContext(r.Context())
return s.Sync.ResetAll(ctx) 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 { if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress") 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) { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context()) remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.mu.Lock() s.mu.Lock()
inProgress := s.syncRunning inProgress := s.userSyncRunning[userID]
s.mu.Unlock() 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{ resp := map[string]any{
"in_progress": inProgress, "in_progress": inProgress,

View File

@@ -1,20 +1,21 @@
package api package api
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"testing" "testing"
"geniusrun/backend/internal/auth" "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) { func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
s, db := newTestServer(t) s, _, userID := newTestServer(t)
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
var gotUserID int64 var gotUserID int64
var gotOK bool var gotOK bool
@@ -31,7 +32,17 @@ func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
} }
func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(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 var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {