Files
geniusrun/backend/internal/sync/service_test.go

285 lines
9.5 KiB
Go
Raw Normal View History

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 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, MaxHR: 190}, 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 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)
}
}
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 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)
}
}