Sync now now runs Backfill (resumes from the watermark, so a widened history horizon is picked up automatically) then IncrementalSync then detail-fill, in one click. "Full backfill" is gone -- in its place, "Reset all" (styled as a destructive action, gated by a confirm dialog) wipes every synced activity and its laps/kind assignments and rewinds the backfill watermark, so the next sync performs a genuinely fresh pull instead of trying to patch up existing rows with newer schema fields.
554 lines
20 KiB
Go
554 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 }
|
|
}
|
|
|
|
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{BackfillHorizonDays: 10, BackfillWindowDays: 10},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
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{BackfillHorizonDays: 10, BackfillWindowDays: 10},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
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{BackfillHorizonDays: 10, BackfillWindowDays: 10}, now)
|
|
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.
|
|
svc2 := NewService(m, db, Config{BackfillHorizonDays: 30, 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)
|
|
}
|
|
}
|