From f7e7b68078f87bb0ac0f892e145fb050b4751e18 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Mon, 27 Jul 2026 09:01:30 +0200 Subject: [PATCH] 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 --- backend/internal/garmin/mock/mock.go | 16 +++++- backend/internal/sync/service_test.go | 75 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/backend/internal/garmin/mock/mock.go b/backend/internal/garmin/mock/mock.go index ddeac98..e17c07e 100644 --- a/backend/internal/garmin/mock/mock.go +++ b/backend/internal/garmin/mock/mock.go @@ -4,6 +4,7 @@ package mock import ( "context" + "time" "geniusrun/backend/internal/garmin" ) @@ -20,7 +21,13 @@ type Client struct { // all-calls-fail Err field below -- so a test can simulate one // activity's workout fetch failing while others in the same batch // succeed. - WorkoutErrByID map[int64]error + WorkoutErrByID map[int64]error + // Delay, if set, is slept (ctx-cancellable) at the start of every + // GetActivities call -- lets a test give sync.Service's discovering + // phase (backfillCore/incrementalSyncCore) real wall-clock duration, so + // a concurrent goroutine can observe Progress() mid-flight instead of + // the call returning instantly. + Delay time.Duration Err error // if set, every call returns this error authResultCursor int ClosedCalled bool @@ -58,6 +65,13 @@ func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResul func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) { c.GetActivitiesCalls++ + if c.Delay > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(c.Delay): + } + } if c.Err != nil { return nil, c.Err } diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index 490053e..236ad4d 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -630,6 +630,81 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { } } +// 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()