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:
@@ -4,6 +4,7 @@ package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
@@ -21,6 +22,12 @@ type Client struct {
|
||||
// activity's workout fetch failing while others in the same batch
|
||||
// succeed.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user