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

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

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