api: scope activities, sync, review-queue, and reclassify handlers to userID
Completes the internal/api scoping pass -- the whole package now compiles against the per-user store/sync/garmin signatures from Tasks 4-13. Also fixes the test helpers (newTestServer now returns the provisioned userID) and a latent bug in TestResolveUser_LeavesContextEmptyWhenNotProvisioned, which relied on doJSON's hardcoded "test-user" session sub being unprovisioned -- never caught before since internal/api couldn't compile since Task 12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ type activityListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user