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>
This commit is contained in:
2026-07-27 09:01:30 +02:00
parent 3a7f305221
commit f7e7b68078
2 changed files with 90 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ package mock
import ( import (
"context" "context"
"time"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
) )
@@ -21,6 +22,12 @@ type Client struct {
// activity's workout fetch failing while others in the same batch // activity's workout fetch failing while others in the same batch
// succeed. // 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 Err error // if set, every call returns this error
authResultCursor int authResultCursor int
ClosedCalled bool 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) { func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
c.GetActivitiesCalls++ c.GetActivitiesCalls++
if c.Delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.Delay):
}
}
if c.Err != nil { if c.Err != nil {
return nil, c.Err return nil, c.Err
} }

View File

@@ -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) { func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()