Files
geniusrun/backend/internal/sync/service_test.go
Christophe Vila f7e7b68078 test(sync): cover FullSync's phase transitions end-to-end
TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases
only ever called FillPendingDetails directly, never exercising
FullSync's own setProgress(PhaseDiscovering, ...) / deferred
setProgress(PhaseIdle, ...) wiring around backfillCore/
incrementalSyncCore -- what handleSyncRun actually runs in production
and what SyncModal's "Discovering activities..." spinner depends on.

Add a mock.Client.Delay field (slept, ctx-cancellable, at the start of
GetActivities) so a test can give the discovering phase real
wall-clock duration, then add
TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle, which
runs FullSync in a goroutine and polls Progress() to confirm it
observes PhaseDiscovering mid-flight and PhaseIdle after completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:30 +02:00

948 lines
36 KiB
Go

package sync
import (
"context"
"fmt"
"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 TestBackfillCore_StoresActivities(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)))
total, err := svc.backfillCore(ctx)
if err != nil {
t.Fatalf("backfillCore: %v", err)
}
if total != 1 {
t.Errorf("backfillCore returned total = %d, want 1", total)
}
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)
}
}
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(len(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(len(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(len(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 TestBackfillCore_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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %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 TestBackfillCore_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.backfillCore(ctx); err != nil {
t.Fatalf("first backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("second backfillCore: %v", err)
}
if m.GetActivitiesCalls != firstCallCount {
t.Errorf("second backfillCore 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.backfillCore(ctx); err != nil {
t.Fatalf("first backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore 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 TestBackfillCore_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.backfillCore(ctx); err != nil {
t.Fatalf("first backfillCore: %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.backfillCore(ctx); err != nil {
t.Fatalf("second backfillCore: %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))
}
}
// waitForPhase polls svc.Progress() until it reports phase or timeout
// elapses, returning whether it was observed. Polling (rather than a single
// fixed-delay sample) keeps this robust against slower/loaded CI machines.
func waitForPhase(svc *Service, phase string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if svc.Progress().Phase == phase {
return true
}
if time.Now().After(deadline) {
return svc.Progress().Phase == phase
}
time.Sleep(2 * time.Millisecond)
}
}
// TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle exercises
// FullSync itself (the actual production call path via handleSyncRun),
// unlike TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases
// below, which only calls FillPendingDetails directly and so never touches
// FullSync's own s.setProgress(PhaseDiscovering, ...) / deferred
// s.setProgress(PhaseIdle, ...) wiring around backfillCore/
// incrementalSyncCore -- what the frontend's SyncModal "Discovering
// activities..." spinner actually depends on.
func TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const delay = 200 * time.Millisecond
workoutID := int64(101)
m := &mock.Client{
Delay: delay, // gives backfillCore/incrementalSyncCore's GetActivities calls real wall-clock duration
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{1: {ActivityID: 1}},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]garmin.Workout{workoutID: {}},
}
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 p := svc.Progress(); p.Phase != PhaseIdle {
t.Fatalf("Progress before FullSync = %+v, want PhaseIdle", p)
}
done := make(chan error, 1)
go func() { done <- svc.FullSync(ctx, 10) }()
// FullSync sets PhaseDiscovering synchronously before backfillCore even
// runs; the mock's Delay on each of backfillCore's and
// incrementalSyncCore's one GetActivities call (~2*delay total) gives
// this comfortable real wall-clock time to observe it before the run
// reaches FillPendingDetails/Idle.
if !waitForPhase(svc, PhaseDiscovering, delay) {
t.Fatalf("Progress().Phase never observed as %q while backfillCore/incrementalSyncCore were running; got %+v", PhaseDiscovering, svc.Progress())
}
select {
case err := <-done:
if err != nil {
t.Fatalf("FullSync: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("FullSync did not complete in time")
}
if final := svc.Progress(); final.Phase != PhaseIdle || final.Done != 0 || final.Total != 0 {
t.Errorf("Progress after FullSync returns = %+v, want zero-value PhaseIdle", final)
}
}
func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const n = 2
m := &mock.Client{
Activities: []garmin.Activity{},
Splits: map[int64]garmin.ActivitySplits{},
Details: map[int64]garmin.ActivityDetails{},
Workouts: map[int64]garmin.Workout{},
}
for i := int64(1); i <= n; i++ {
workoutID := i + 100
m.Activities = append(m.Activities, garmin.Activity{
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
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}
m.Workouts[workoutID] = garmin.Workout{}
}
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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
}
if p := svc.Progress(); p.Phase != PhaseIdle {
t.Fatalf("Progress before FillPendingDetails = %+v, want PhaseIdle", p)
}
done := make(chan error, 1)
go func() { done <- svc.FillPendingDetails(ctx, n) }()
// Timeline with InterCallDelay=150ms and n=2 per phase: activities pass
// does item0 (instant), delays ~150ms, does item1 -- landing here around
// t=75ms should be mid-delay in the activities phase.
time.Sleep(75 * time.Millisecond)
midActivities := svc.Progress()
if midActivities.Phase != PhaseActivities {
t.Errorf("mid-activities Progress().Phase = %q, want %q", midActivities.Phase, PhaseActivities)
}
if midActivities.Total != n {
t.Errorf("mid-activities Progress().Total = %d, want %d", midActivities.Total, n)
}
if midActivities.Done != 1 {
t.Errorf("mid-activities Progress().Done = %d, want 1", midActivities.Done)
}
// The activities phase finishes its own delay+item1 around t=150ms, then
// the workouts phase starts: item0 (instant), delay ~150ms, item1.
// Landing around t=150+75=225ms should be mid-delay in the workouts phase.
time.Sleep(150 * time.Millisecond)
midWorkouts := svc.Progress()
if midWorkouts.Phase != PhaseWorkouts {
t.Errorf("mid-workouts Progress().Phase = %q, want %q", midWorkouts.Phase, PhaseWorkouts)
}
if midWorkouts.Total != n {
t.Errorf("mid-workouts Progress().Total = %d, want %d", midWorkouts.Total, n)
}
if midWorkouts.Done != 1 {
t.Errorf("mid-workouts Progress().Done = %d, want 1", midWorkouts.Done)
}
if err := <-done; err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
if final := svc.Progress(); final.Phase != PhaseIdle || final.Total != 0 || final.Done != 0 {
t.Errorf("Progress after completion = %+v, want zero-value PhaseIdle", final)
}
}
func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetched(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 77
const workoutID = 888
workoutIDPtr := int64(workoutID)
id, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: garminActivityID, WorkoutID: &workoutIDPtr,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// Simulate a prior run that successfully fetched details/splits but
// whose workout fetch failed (workout_raw_json stays NULL).
if err := db.SetActivityDetails(ctx, userID, id, "{}"); err != nil {
t.Fatalf("SetActivityDetails: %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, id); err != nil {
t.Fatalf("SetActivitySplitsFetched: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, id, []store.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
m := &mock.Client{
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)))
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
// return nothing, since details are already fetched) still reaches this
// activity via ActivitiesMissingWorkout.
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
laps, err := db.LapsForActivity(ctx, userID, 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 (workout fetch should have been retried)", laps[0].TargetPaceLowMps)
}
}
func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const failingWorkoutID = 200
const okWorkoutID = 201
failingPtr, okPtr := int64(failingWorkoutID), int64(okWorkoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
StartTimeGMT: "2026-07-02 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
2: {ActivityID: 2, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
Workouts: map[int64]garmin.Workout{
okWorkoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
WorkoutErrByID: map[int64]error{failingWorkoutID: fmt.Errorf("garmin says no")},
}
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
// activities/2 workouts here don't cost real wall-clock seconds
// (Config{}'s default is 1s per gap).
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails should not return an error for a per-activity workout fetch failure: %v", err)
}
remaining, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if remaining != 1 {
t.Errorf("CountActivitiesMissingWorkout = %d, want 1 (the failing activity stays pending, retryable next sync)", remaining)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
var okActivity store.Activity
for _, a := range activities {
if a.GarminActivityID == 2 {
okActivity = a
}
}
laps, err := db.LapsForActivity(ctx, userID, okActivity.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("the succeeding activity's TargetPaceLowMps = %v, want 3.0 (should not be affected by the other activity's failure)", laps[0].TargetPaceLowMps)
}
}
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.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore(a): %v", err)
}
if _, err := svcB.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore(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)
}
}