From 69103b7a4bee462461ff038cfbca73da505f4fca Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 17:45:41 +0200 Subject: [PATCH] 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. --- backend/internal/sync/service.go | 76 ++++++++------- backend/internal/sync/service_test.go | 135 +++++++++++++++++++------- 2 files changed, 138 insertions(+), 73 deletions(-) diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index b921544..080c713 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -61,10 +61,12 @@ type Progress struct { Total int } -// Service is the sync orchestrator. +// Service is the sync orchestrator, scoped to one user -- every store call +// it makes is for userID's data only. type Service struct { garmin garmin.Client db *store.DB + userID int64 cfg Config now func() time.Time @@ -72,13 +74,13 @@ type Service struct { progress Progress } -// NewService builds a Service. now defaults to time.Now if nil (tests can -// override it for deterministic date windows). -func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service { +// NewService builds a Service scoped to userID. now defaults to time.Now if +// nil (tests can override it for deterministic date windows). +func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service { if now == nil { now = time.Now } - return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now} + return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now} } // Progress returns the current detail-fill progress (0/0 when idle). @@ -106,7 +108,7 @@ func (s *Service) setProgress(done, total int) { // history. Widening the horizon between calls resumes further back instead // of re-fetching everything. func (s *Service) Backfill(ctx context.Context) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill) if err != nil { return err } @@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error { total, err := s.backfillCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, total, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil) } // backfillCore holds Backfill's actual fetch logic, without the SyncRun @@ -126,13 +128,13 @@ func (s *Service) Backfill(ctx context.Context) error { // whatever was fetched even when an error is also returned, matching // Backfill's own partial-progress-on-error behavior. func (s *Service) backfillCore(ctx context.Context) (int, error) { - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return 0, fmt.Errorf("load profile: %w", err) } horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays) - state, err := s.db.GetSyncState(ctx) + state, err := s.db.GetSyncState(ctx, s.userID) if err != nil { return 0, err } @@ -167,12 +169,12 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { // Empty page: reached the start of this account's history, // regardless of the configured horizon. reachedStartOfHistory = true - if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), true); err != nil { return total, err } break } - if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), false); err != nil { return total, err } end = start.AddDate(0, 0, -1) @@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { if !reachedStartOfHistory { // Reached the configured horizon (not Garmin's actual history // start) -- mark complete relative to that horizon. - if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(horizon), true); err != nil { return total, err } } @@ -192,7 +194,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { // IncrementalSync fetches activities from just before the latest known // activity (or a short recent window if none exist yet) through today. func (s *Service) IncrementalSync(ctx context.Context) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental) if err != nil { return err } @@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error { n, err := s.incrementalSyncCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, n, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, n, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil) } // incrementalSyncCore holds IncrementalSync's actual fetch logic, without // the SyncRun bookkeeping -- see backfillCore. func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) { start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays) - if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok { + if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok { if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil { start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays) } @@ -229,7 +231,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) { // would silently hide however many activities Backfill fetched. Recording // one combined run makes the reported count match the whole action. func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull) if err != nil { return err } @@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { backfillCount, err := s.backfillCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, backfillCount, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg) return err } @@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { total := backfillCount + incrementalCount if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, total, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil) } // ResetAll deletes every synced activity (and its laps/samples/kind @@ -263,7 +265,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { // call performs a genuinely fresh pull from Garmin instead of resuming from // wherever the previous one left off. Workout kinds are left untouched. func (s *Service) ResetAll(ctx context.Context) error { - return s.db.ResetAllSyncedData(ctx) + return s.db.ResetAllSyncedData(ctx, s.userID) } // fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's @@ -290,11 +292,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st if !isRunningActivityType(a.ActivityType.TypeKey) { continue } - exists, err := s.db.ActivityExists(ctx, a.ActivityID) + exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID) if err != nil { return 0, 0, err } - if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil { + if _, err := s.db.UpsertActivity(ctx, s.userID, toActivityRow(a)); err != nil { return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err) } if !exists { @@ -309,11 +311,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st // Calls are made sequentially with Config.InterCallDelay between them to // avoid Garmin/Cloudflare rate limiting. func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { - pending, err := s.db.ActivitiesMissingDetails(ctx, limit) + pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit) if err != nil { return err } - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return fmt.Errorf("load profile: %w", err) } @@ -357,41 +359,41 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err) } else { targets = alignWorkoutTargets(splits.Laps, workout) - if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil { + if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil { return err } } } samples := garmin.ExtractSamples(details) - if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil { + if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil { return err } - if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { + if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { return err } - if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil { + if err := s.db.SetActivityDetails(ctx, s.userID, a.ID, string(details.Raw)); err != nil { return err } - return s.db.SetActivitySplitsFetched(ctx, a.ID) + return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID) } // ClassifyActivity (re)runs the rule engine for one activity against the // currently active workout kinds and appends a new kind_assignments row. // Safe to call repeatedly (e.g. after editing a workout kind's rule). func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { - activity, ok, err := s.db.GetActivity(ctx, activityID) + activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID) if err != nil { return err } if !ok { return fmt.Errorf("activity %d not found", activityID) } - laps, err := s.db.LapsForActivity(ctx, activityID) + laps, err := s.db.LapsForActivity(ctx, s.userID, activityID) if err != nil { return err } - kindRows, err := s.db.ListWorkoutKinds(ctx, true) + kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true) if err != nil { return err } @@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error if err != nil { return fmt.Errorf("parse workout kind rules: %w", err) } - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return fmt.Errorf("load profile: %w", err) } @@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error return err } - _, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{ + _, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: result.WorkoutKindID, AssignmentSource: store.AssignmentSourceRuleEngine, diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index 471cd20..3b93bae 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -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) + } +}