SMARTRUN_BACKFILL_HORIZON_DAYS was a server-startup-only env var with no UI, defaulting to 3 years -- so editing the unrelated "Rolling window" profile field (for classification, not sync) had no effect on how far back Sync Now reached. Backfill horizon is now Profile.BackfillHorizonDays, read fresh on every Backfill call, with its own field on the Profile page.
573 lines
20 KiB
Go
573 lines
20 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"smartrun/backend/internal/classify"
|
|
"smartrun/backend/internal/garmin"
|
|
"smartrun/backend/internal/garmin/mock"
|
|
"smartrun/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(), "smartrun_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 }
|
|
}
|
|
|
|
// 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) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
profile, err := db.GetProfile(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetProfile: %v", err)
|
|
}
|
|
profile.BackfillHorizonDays = days
|
|
if err := db.UpdateProfile(ctx, profile); err != nil {
|
|
t.Fatalf("UpdateProfile: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
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)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
|
|
activities, err := db.ListActivities(ctx, 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, 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_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()
|
|
|
|
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, 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{})
|
|
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()
|
|
|
|
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, 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, 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, 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, activityID)
|
|
if err != nil || len(laps) != 1 {
|
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
}
|
|
|
|
assignment, ok, err := db.CurrentAssignment(ctx, 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()
|
|
|
|
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, 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, store.ActivityFilter{})
|
|
if err != nil || len(activities) != 1 {
|
|
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
|
}
|
|
laps, err := db.LapsForActivity(ctx, 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_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
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{
|
|
// Only one step for two recorded laps -- counts don't match.
|
|
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, 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, store.ActivityFilter{})
|
|
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
|
if err != nil || len(laps) != 2 {
|
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
}
|
|
for i, l := range laps {
|
|
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
|
|
t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.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()
|
|
|
|
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},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
setBackfillHorizon(t, db, 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)
|
|
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()
|
|
|
|
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},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
setBackfillHorizon(t, db, 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, 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, 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()
|
|
|
|
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)
|
|
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, 30)
|
|
svc2 := NewService(m, db, 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)
|
|
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 TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
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, 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)
|
|
}
|
|
}
|