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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,13 @@ type Client struct {
|
|||||||
// all-calls-fail Err field below -- so a test can simulate one
|
// all-calls-fail Err field below -- so a test can simulate one
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user