sync: scope Service to one user per instance

NewService now takes a userID, baked into the instance rather than passed
per-call -- matches internal/api's one-Service-per-logged-in-user model
(Task 13), so ClassifyActivity/Backfill/etc. keep their existing call
signatures unchanged everywhere they're already used.
This commit is contained in:
2026-07-25 17:45:41 +02:00
parent 3a1333017d
commit 69103b7a4b
2 changed files with 138 additions and 73 deletions

View File

@@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t }
}
func provisionTestUser(t *testing.T, db *store.DB) int64 {
t.Helper()
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
return userID
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
t.Helper()
ctx := context.Background()
profile, err := db.GetProfile(ctx)
profile, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, profile); err != nil {
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
}
@@ -46,18 +55,19 @@ func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
}
runs, err := db.ListSyncRuns(ctx, 10)
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
@@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
@@ -199,13 +210,13 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 42
m := &mock.Client{
@@ -244,7 +256,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
},
},
}
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -252,7 +264,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
// A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
@@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
@@ -273,12 +285,12 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
}
laps, err := db.LapsForActivity(ctx, activityID)
laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
@@ -290,6 +302,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 55
const workoutID = 999
@@ -313,7 +326,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
}},
},
}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
laps, err := db.LapsForActivity(ctx, activities[0].ID)
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
@@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 56
const workoutID = 1000
@@ -369,7 +383,7 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
}},
},
}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
t.Fatalf("FillPendingDetails: %v", err)
}
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, activities[0].ID)
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 2 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
@@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -459,7 +474,7 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
t.Fatal("expected first backfill to call GetActivities at least once")
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if err := svc.ResetAll(ctx); err != nil {
t.Fatalf("ResetAll: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
}
activities, err = db.ListActivities(ctx, store.ActivityFilter{})
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities after re-backfill: %v", err)
}
@@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, 10)
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
@@ -537,8 +554,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, 30)
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
@@ -546,7 +563,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{
@@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err)
}
runs, err := db.ListSyncRuns(ctx, 10)
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
@@ -606,7 +624,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{},
@@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
}
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -663,3 +682,47 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
}
}
func TestService_TwoUsersSyncIndependently(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svcA.Backfill(ctx); err != nil {
t.Fatalf("Backfill(a): %v", err)
}
if err := svcB.Backfill(ctx); err != nil {
t.Fatalf("Backfill(b): %v", err)
}
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(a): %v", err)
}
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(b): %v", err)
}
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
}
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
}
}