package sync import ( "context" "path/filepath" "testing" "time" "geniusrun/backend/internal/classify" "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/store" ) func f(v float64) *float64 { return &v } func openTestDB(t *testing.T) *store.DB { t.Helper() 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() }) return db } 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, userID int64, days int) { t.Helper() ctx := context.Background() profile, err := db.GetProfile(ctx, userID) if err != nil { t.Fatalf("GetProfile: %v", err) } profile.BackfillHorizonDays = days if err := db.UpdateProfile(ctx, userID, profile); err != nil { t.Fatalf("UpdateProfile: %v", err) } } 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, 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, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) != 1 { t.Fatalf("expected 1 stored activity, got %d", len(activities)) } if activities[0].GarminActivityID != 1 { t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID) } runs, err := db.ListSyncRuns(ctx, userID, 10) if err != nil { t.Fatalf("ListSyncRuns: %v", err) } if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess { t.Fatalf("expected 1 successful sync run, got %+v", runs) } } func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) { laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}} workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)}, {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}}, }}, }} targets := alignWorkoutTargets(laps, workout) if len(targets) != 2 { t.Fatalf("expected 2 aligned targets, got %d", len(targets)) } if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" { t.Errorf("targets[0] = %+v, want pace.zone step", targets[0]) } if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" { t.Errorf("targets[1] = %+v, want no.target step", targets[1]) } } func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) { // Confirmed via Garmin Connect against a real activity: recording // sometimes continues one lap past the workout's last step (e.g. a // 5-minute prescribed cool-down followed by another 6:46 the athlete // just kept running). The extra lap should have no target, but every // other lap's real target must still come through. laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}} workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)}, {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)}, }}, }} targets := alignWorkoutTargets(laps, workout) if len(targets) != 3 { t.Fatalf("expected 3 slots (one per lap), got %d", len(targets)) } if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" { t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0]) } if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" { t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1]) } if targets[2] != nil { t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2]) } } func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) { laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}} workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO"}, }}, }} targets := alignWorkoutTargets(laps, workout) if len(targets) != 3 { t.Fatalf("expected 3 slots (one per lap), got %d", len(targets)) } for i, target := range targets { if target != nil { t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target) } } } func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) { lo, hi := targetPaceRange(garmin.WorkoutStep{ TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3), }) if lo == nil || hi == nil || *lo != 3 || *hi != 4 { t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi) } lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)}) if lo != nil || hi != nil { t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi) } } func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) { lo, hi := targetHRRange(garmin.WorkoutStep{ TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150), }, store.Profile{}) if lo == nil || hi == nil || *lo != 150 || *hi != 160 { t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi) } zone := 3 profile := store.Profile{ MaxHeartRate: f(190), RestingHeartRate: f(50), HRZone3MinPct: 70, HRZone3MaxPct: 80, } lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile) // Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162 if lo == nil || hi == nil || *lo != 148 || *hi != 162 { t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi) } lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{}) if lo != nil || hi != nil { t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi) } } 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"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"}, StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400}, {ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"}, StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800}, {ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"}, StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800}, }} 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, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) != 2 { t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities) } for _, a := range activities { if a.GarminActivityID != 1 && a.GarminActivityID != 2 { t.Errorf("unexpected non-running activity stored: %+v", a) } } } func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { db := openTestDB(t) ctx := context.Background() userID := provisionTestUser(t, db) const garminActivityID = 42 m := &mock.Client{ Activities: []garmin.Activity{ {ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145}, }, Splits: map[int64]garmin.ActivitySplits{ garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{ {LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"}, }}, }, Details: map[int64]garmin.ActivityDetails{ garminActivityID: { ActivityID: garminActivityID, MetricDescriptors: []garmin.MetricDescriptor{ {Key: "directHeartRate", MetricsIndex: 0}, {Key: "sumElapsedDuration", MetricsIndex: 1}, }, }, }, } 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) } // 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, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } if err := svc.FillPendingDetails(ctx, 10); err != nil { t.Fatalf("FillPendingDetails: %v", err) } activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil || len(activities) != 1 { t.Fatalf("ListActivities: %v, %+v", err, activities) } activityID := activities[0].ID if activities[0].DetailsFetchedAt == nil { t.Error("expected DetailsFetchedAt to be set after FillPendingDetails") } if activities[0].SplitsFetchedAt == nil { t.Error("expected SplitsFetchedAt to be set after FillPendingDetails") } 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, userID, activityID) if err != nil || !ok { t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) } if assignment.Status != classify.StatusAssigned { t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON) } } func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { db := openTestDB(t) ctx := context.Background() userID := provisionTestUser(t, db) const garminActivityID = 55 const workoutID = 999 workoutIDPtr := int64(workoutID) m := &mock.Client{ Activities: []garmin.Activity{ {ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, }, Splits: map[int64]garmin.ActivitySplits{ garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{ {LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"}, }}, }, Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}}, Workouts: map[int64]garmin.Workout{ workoutID: {Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)}, }}, }}, }, } 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) } if err := svc.FillPendingDetails(ctx, 10); err != nil { t.Fatalf("FillPendingDetails: %v", err) } 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, userID, activities[0].ID) if err != nil || len(laps) != 1 { t.Fatalf("LapsForActivity: %v, %+v", err, laps) } if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 { t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps) } if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 { t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps) } } func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) { db := openTestDB(t) ctx := context.Background() userID := provisionTestUser(t, db) const garminActivityID = 56 const workoutID = 1000 workoutIDPtr := int64(workoutID) m := &mock.Client{ Activities: []garmin.Activity{ {ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, }, Splits: map[int64]garmin.ActivitySplits{ garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{ {LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"}, {LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"}, }}, }, Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}}, Workouts: map[int64]garmin.Workout{ // One step for two recorded laps -- the second lap is an // unplanned continuation past the workout's end (confirmed via // Garmin Connect against a real activity), not a genuine // mismatch, so the first lap should still get a real target. workoutID: {Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)}, }}, }}, }, } 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) } if err := svc.FillPendingDetails(ctx, 10); err != nil { t.Fatalf("FillPendingDetails: %v", err) } 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) } if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 { t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps) } if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil { t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps) } } func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) { activity := store.Activity{ DurationSeconds: 1800, DistanceMeters: 6000, AvgSpeedMps: f(3.33), AvgHR: f(152), AerobicTrainingEffect: f(3.2), } laps := []store.Lap{ {IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)}, {IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)}, } ctx := buildMetricContext(activity, laps, 190) if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 { t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got) } if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want { t.Errorf("avg_hr_pct_max = %v, want %v", got, want) } if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 { t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got) } if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 { t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got) } // Only one ACTIVE + one REST lap, not repeated -- should not look like a // structured interval workout. if got := ctx["lap_interval_pattern"]; got != 0 { t.Errorf("lap_interval_pattern = %v, want 0", got) } if got := ctx["is_race"]; got != 0 { t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got) } } func TestBuildMetricContext_DerivesIsRace(t *testing.T) { ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0) if got := ctx["is_race"]; got != 1 { t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got) } ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0) if got := ctx["is_race"]; got != 0 { t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got) } } 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, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) } firstCallCount := m.GetActivitiesCalls if firstCallCount == 0 { t.Fatal("expected first backfill to call GetActivities at least once") } state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } if !state.BackfillComplete { t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state) } if err := svc.Backfill(ctx); err != nil { t.Fatalf("second Backfill: %v", err) } if m.GetActivitiesCalls != firstCallCount { t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)", m.GetActivitiesCalls-firstCallCount) } } 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, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) } firstCallCount := m.GetActivitiesCalls if err := svc.ResetAll(ctx); err != nil { t.Fatalf("ResetAll: %v", err) } activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) != 0 { t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities)) } if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill after reset: %v", err) } 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, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities after re-backfill: %v", err) } if len(activities) != 1 { t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities)) } } 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, userID, Config{BackfillWindowDays: 10}, now) setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) } firstCallCount := m.GetActivitiesCalls // 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, userID, 30) svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now) if err := svc2.Backfill(ctx); err != nil { t.Fatalf("second Backfill: %v", err) } if m.GetActivitiesCalls <= firstCallCount { t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount) } state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } if !state.BackfillComplete { t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state) } } func TestFullSync_RecordsOneCombinedSyncRun(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}, {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500}, }, Splits: map[int64]garmin.ActivitySplits{ 1: {ActivityID: 1}, 2: {ActivityID: 2}, }, Details: map[int64]garmin.ActivityDetails{ 1: {ActivityID: 1}, 2: {ActivityID: 2}, }, } svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) setBackfillHorizon(t, db, userID, 10) if err := svc.FullSync(ctx, 10); err != nil { t.Fatalf("FullSync: %v", err) } runs, err := db.ListSyncRuns(ctx, userID, 10) if err != nil { t.Fatalf("ListSyncRuns: %v", err) } if len(runs) != 1 { t.Fatalf("expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v", len(runs), runs) } run := runs[0] if run.Kind != store.SyncKindFull { t.Errorf("Kind = %q, want %q", run.Kind, store.SyncKindFull) } if run.Status != store.SyncStatusSuccess { t.Errorf("Status = %q, want success", run.Status) } // Backfill (one window covering the whole horizon) stores both of the // mock's activities as genuinely new (2). IncrementalSync then runs its // own separate fetch and -- since the fake client ignores the date range // it's called with -- sees the exact same 2 activities again, but they're // already stored by then, so it contributes 0 new ones. The combined // run's count (2) must reflect that dedup, not naively sum each stage's // raw fetch count (which would double-count to 4) or report only // whichever stage happened to run last (which would silently drop // Backfill's count) -- both are bugs this test guards against. if run.ActivitiesFetched != 2 { t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched) } activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) != 2 { t.Fatalf("expected 2 stored activities (upserted, not duplicated), got %d", len(activities)) } } func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) { db := openTestDB(t) ctx := context.Background() userID := provisionTestUser(t, db) m := &mock.Client{ Activities: []garmin.Activity{}, Splits: map[int64]garmin.ActivitySplits{}, Details: map[int64]garmin.ActivityDetails{}, } const n = 3 for i := int64(1); i <= n; i++ { m.Activities = append(m.Activities, garmin.Activity{ ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500, }) m.Splits[i] = garmin.ActivitySplits{ActivityID: i} m.Details[i] = garmin.ActivityDetails{ActivityID: i} } 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) } if p := svc.Progress(); p.Total != 0 { t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p) } done := make(chan error, 1) go func() { done <- svc.FillPendingDetails(ctx, n) }() time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item mid := svc.Progress() if mid.Total != n { t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n) } if mid.Done <= 0 || mid.Done >= n { t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n) } if err := <-done; err != nil { t.Fatalf("FillPendingDetails: %v", err) } if final := svc.Progress(); final.Total != 0 || final.Done != 0 { 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) } }