refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func newCtx() context.Context { return context.Background() }
|
||||
@@ -47,9 +45,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, userID
|
||||
}
|
||||
|
||||
@@ -586,7 +584,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -607,9 +605,9 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +631,7 @@ func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
||||
t.Fatalf("SetActivityDetails: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/sync/status", nil)
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -777,7 +775,7 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
t.Fatal("expected a fresh account to report garmin_connected=false")
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -792,18 +790,18 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
|
||||
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||
s, _, userID := newTestServer(t)
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -821,17 +819,17 @@ func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
@@ -882,9 +880,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// recordAuthResult updates the in-memory auth status/message for userID,
|
||||
// and -- on a successful authentication -- persists that this account has
|
||||
// connected to Garmin at least once (store.MarkGarminConnected), which is
|
||||
// what the login gate actually checks (the in-memory auth status resets on
|
||||
// every backend restart; this doesn't).
|
||||
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
s.userAuthStatus[userID] = res.Status
|
||||
s.userAuthMessage[userID] = res.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if res.Status == garmin.AuthSuccess {
|
||||
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
|
||||
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type EnvVar struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type appConfigEntry struct {
|
||||
type configEntry struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
@@ -30,13 +30,13 @@ func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||||
return nil, err
|
||||
}
|
||||
registry := config.AppRegistry()
|
||||
app := make([]appConfigEntry, 0, len(registry))
|
||||
app := make([]configEntry, 0, len(registry))
|
||||
for _, k := range registry {
|
||||
value, overridden := overrides[k.Key]
|
||||
if !overridden {
|
||||
value = k.Default
|
||||
}
|
||||
app = append(app, appConfigEntry{
|
||||
app = append(app, configEntry{
|
||||
Key: k.Key, Value: value, Default: k.Default,
|
||||
Overridden: overridden, Description: k.Description,
|
||||
})
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type configTestResponse struct {
|
||||
@@ -87,8 +84,8 @@ func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||
200
backend/internal/api/garmin.go
Normal file
200
backend/internal/api/garmin.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
type authResponse struct {
|
||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func authStatusString(s garmin.AuthStatus) string {
|
||||
switch s {
|
||||
case garmin.AuthSuccess:
|
||||
return "authenticated"
|
||||
case garmin.AuthMFARequired:
|
||||
return "mfa_required"
|
||||
case garmin.AuthFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// recordAuthResult updates the in-memory auth status/message for userID,
|
||||
// and -- on a successful authentication -- persists that this account has
|
||||
// connected to Garmin at least once (store.MarkGarminConnected), which is
|
||||
// what the login gate actually checks (the in-memory auth status resets on
|
||||
// every backend restart; this doesn't).
|
||||
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
s.userAuthStatus[userID] = res.Status
|
||||
s.userAuthMessage[userID] = res.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if res.Status == garmin.AuthSuccess {
|
||||
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
|
||||
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.Authenticate(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Code == "" {
|
||||
writeError(w, http.StatusBadRequest, "code is required")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAuthResult(r.Context(), userID, res)
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
s.mu.Lock()
|
||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||
}
|
||||
|
||||
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"progress": progress,
|
||||
"activities_pending_details": pendingDetails,
|
||||
"workouts_pending": pendingWorkouts,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||||
@@ -150,8 +148,8 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||
if rec.Code != http.StatusForbidden {
|
||||
@@ -196,7 +194,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -98,6 +98,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeGarminClient(userID)
|
||||
s.removeUserClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -18,35 +19,34 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||||
// and sync.Service are per-user (each user might have their own Garmin
|
||||
// account), built lazily on first use via GarminFactory and cached.
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
Session SessionConfig
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
SessionConfig SessionConfig
|
||||
|
||||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||
// fully-resolved per-user Config. Production wiring passes
|
||||
// garmin.NewClient; tests inject a factory returning a shared
|
||||
// *mock.Client (see newTestServer in api_test.go).
|
||||
GarminFactory func(garmin.Config) garmin.Client
|
||||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
||||
GarminFactory func(garmin.ClientConfig) garmin.Client
|
||||
|
||||
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
||||
// (the python interpreter path + the token-store root directory); only
|
||||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||
// garminFor.
|
||||
GarminBase garmin.Config
|
||||
SyncConfig appsync.Config
|
||||
ClientConfig garmin.ClientConfig
|
||||
SyncConfig garmin.SyncConfig
|
||||
|
||||
// EnvVars is the read-only, display-safe environment-configuration
|
||||
// snapshot served by GET /api/config -- built once in main.go from
|
||||
@@ -55,260 +55,36 @@ type Server struct {
|
||||
EnvVars []EnvVar
|
||||
|
||||
mu sync.Mutex
|
||||
userGarmin map[int64]garmin.Client
|
||||
userSync map[int64]*appsync.Service
|
||||
userClient map[int64]garmin.Client
|
||||
userSync map[int64]*garmin.Sync
|
||||
userAuthStatus map[int64]garmin.AuthStatus
|
||||
userAuthMessage map[int64]string
|
||||
userSyncRunning map[int64]bool
|
||||
setupGarmin map[string]*setupSession
|
||||
setupSession map[string]*setupSession
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
||||
return &Server{
|
||||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||||
Auth: authVerifier, Session: session,
|
||||
userGarmin: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*appsync.Service{},
|
||||
DB: db,
|
||||
GarminFactory: garminFactory,
|
||||
ClientConfig: garminBase,
|
||||
SyncConfig: syncConfig,
|
||||
Auth: authVerifier,
|
||||
SessionConfig: sessionConfig,
|
||||
userClient: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*garmin.Sync{},
|
||||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||
userAuthMessage: map[int64]string{},
|
||||
userSyncRunning: map[int64]bool{},
|
||||
setupGarmin: map[string]*setupSession{},
|
||||
setupSession: map[string]*setupSession{},
|
||||
}
|
||||
}
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userGarmin) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||
// an opaque string from the identity provider and using it verbatim in a
|
||||
// filesystem path would be a directory-traversal risk if it ever contained
|
||||
// path separators.
|
||||
func setupTokenStoreDir(root, sub string) string {
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupGarmin[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupGarmin, sub)
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupGarmin[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupGarmin[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupGarmin[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupGarmin[sub]
|
||||
delete(s.setupGarmin, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
// garminFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userGarmin[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.garminFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeGarminClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userGarmin[userID]
|
||||
delete(s.userGarmin, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.GarminBase.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func requestLoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Router builds the HTTP routes.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLoggingMiddleware)
|
||||
r.Use(loggingMiddleware)
|
||||
r.Use(corsMiddleware)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
@@ -319,11 +95,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/session/callback", s.handleSessionCallback)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireSession(s.Session.Secret))
|
||||
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
||||
r.Use(s.resolveUser)
|
||||
|
||||
r.Get("/session/me", s.handleSessionMe)
|
||||
r.Post("/session/logout", s.handleSessionLogout)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/complete", s.handleSetupComplete)
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
@@ -341,17 +118,20 @@ func (s *Server) Router() http.Handler {
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleAuthLogin)
|
||||
r.Post("/mfa", s.handleAuthMFA)
|
||||
r.Get("/status", s.handleAuthStatus)
|
||||
})
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleGarminAuthLogin)
|
||||
r.Post("/mfa", s.handleGarminAuthMFA)
|
||||
r.Get("/status", s.handleGarminAuthStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleGarminSyncRun)
|
||||
r.Post("/reset", s.handleGarminSyncReset)
|
||||
r.Get("/runs", s.handleGarminSyncRuns)
|
||||
r.Get("/status", s.handleGarminSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleSyncRun)
|
||||
r.Post("/reset", s.handleSyncReset)
|
||||
r.Get("/runs", s.handleSyncRuns)
|
||||
r.Get("/status", s.handleSyncStatus)
|
||||
})
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
@@ -379,6 +159,40 @@ func (s *Server) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// loggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||
// this API. Reflecting any origin back is safe even with credentials
|
||||
// enabled: this remains a single-operator app whose real access control is
|
||||
@@ -399,20 +213,89 @@ func corsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// clientFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userClient[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeUserClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userClient[userID]
|
||||
delete(s.userClient, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.ClientConfig.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.clientFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||
@@ -439,3 +322,25 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||
// an opaque string from the identity provider and using it verbatim in a
|
||||
// filesystem path would be a directory-traversal risk if it ever contained
|
||||
// path separators.
|
||||
func setupTokenStoreDir(root, sub string) string {
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
||||
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -58,36 +58,36 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||
if err != nil {
|
||||
log.Printf("session callback: missing txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
||||
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||
if err != nil {
|
||||
log.Printf("session callback: failed to parse txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||
if err != nil {
|
||||
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !result.Authorized {
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, sessionCookie)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
@@ -99,8 +99,8 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||
s.removeSetupSession(claims.Sub)
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,11 +7,38 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userClient) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||||
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||||
// row exists yet at this point (see setupSession in server.go).
|
||||
@@ -90,7 +117,7 @@ func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||
// credentials, marks it connected, closes the ephemeral client, and
|
||||
// renames its token-store directory into the permanent per-user path --
|
||||
// the next real garminFor(ctx, userID) builds a fresh client from scratch
|
||||
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||
// against that now-permanent directory, whose subprocess's lazy
|
||||
// startup-login resumes the just-renamed, still-valid session without
|
||||
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||
@@ -160,14 +187,14 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
sess.Client.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.setupGarmin, claims.Sub)
|
||||
delete(s.setupSession, claims.Sub)
|
||||
s.userAuthStatus[userID] = sess.Status
|
||||
s.userAuthMessage[userID] = sess.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
|
||||
}
|
||||
@@ -175,3 +202,80 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupSession[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupSession[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupSession[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
delete(s.setupSession, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,23 @@ import (
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||||
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||||
// -- every test in this file needs that starting state, unlike
|
||||
// newTestServer's auto-provisioned default.
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) {
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, m
|
||||
}
|
||||
|
||||
@@ -98,14 +96,14 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
var factoryConfigs []garmin.Config
|
||||
garminFactory := func(cfg garmin.Config) garmin.Client {
|
||||
var factoryConfigs []garmin.ClientConfig
|
||||
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
|
||||
factoryConfigs = append(factoryConfigs, cfg)
|
||||
return m
|
||||
}
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
@@ -145,7 +143,7 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
// A later real use must build a genuinely fresh client, configured
|
||||
// against the permanent {userID} token store directory -- never the
|
||||
// stale ephemeral setup/{hash} one the closed client was carrying.
|
||||
if _, err := s.garminFor(newCtx(), u.ID); err != nil {
|
||||
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||
@@ -210,9 +208,9 @@ func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||
// per sync trigger, matching the sequential rate-limited fetch in
|
||||
// internal/sync.Service.FillPendingDetails.
|
||||
const detailFillBatchSize = 50
|
||||
|
||||
// handleSyncRun does a full sync pass: Backfill first (resumes from the
|
||||
// watermark, so widening the configured history horizon between clicks is
|
||||
// picked up automatically), then IncrementalSync (catches anything new since
|
||||
// the latest known activity), then fills in details for whatever's still
|
||||
// missing them. Activities already fully processed are left untouched --
|
||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||
// FullSync run so "last sync" reports the combined activity count, not just
|
||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.FullSync(ctx, detailFillBatchSize)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
// handleSyncReset wipes every synced activity (and its laps/samples/kind
|
||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||
// gates this behind a confirmation.
|
||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||
return svc.ResetAll(ctx)
|
||||
})
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
|
||||
svc, err := s.syncFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
progress := svc.Progress()
|
||||
|
||||
resp := map[string]any{
|
||||
"in_progress": inProgress,
|
||||
"progress": progress,
|
||||
"activities_pending_details": pendingDetails,
|
||||
"workouts_pending": pendingWorkouts,
|
||||
}
|
||||
if ok {
|
||||
resp["last_run"] = run
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
var gotOK bool
|
||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -63,7 +63,7 @@ type AppConfig struct {
|
||||
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||||
// -- it takes the raw map instead of a *store.DB so this package needs no
|
||||
// store dependency and the merge logic tests without a database. A bad
|
||||
// stored value fails fast, same posture as Load() for env vars.
|
||||
// stored value fails fast, same posture as LoadEnv() for env vars.
|
||||
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||||
merged := map[string]string{}
|
||||
for _, k := range appRegistry {
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestValidateAppValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
cfg := Config{
|
||||
Addr: ":8080", OIDCClientSecret: "hunter2",
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
|
||||
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
entries := map[string]string{}
|
||||
@@ -70,7 +70,7 @@ func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||
}
|
||||
empty := Config{}
|
||||
empty := EnvConfig{}
|
||||
for _, e := range empty.DisplayEnv() {
|
||||
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||
|
||||
@@ -11,18 +11,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds geniusrund's process-level configuration.
|
||||
type Config struct {
|
||||
// Addr is the HTTP listen address, e.g. ":8080".
|
||||
Addr string
|
||||
// EnvConfig holds geniusrund's process-level configuration.
|
||||
type EnvConfig struct {
|
||||
// BackendAddr is the HTTP listen address, e.g. ":8080".
|
||||
BackendAddr string
|
||||
// DBPath is the SQLite database file path.
|
||||
DBPath string
|
||||
|
||||
// GarminPythonPath is the python3 interpreter used to run the embedded
|
||||
// PythonPath is the python3 interpreter used to run the embedded
|
||||
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
|
||||
// Defaults to "python3" resolved via PATH if unset.
|
||||
GarminPythonPath string
|
||||
// GarminTokenStoreRoot is the root directory under which each user's
|
||||
PythonPath string
|
||||
// TokenStoreRoot is the root directory under which each user's
|
||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||
@@ -31,7 +31,7 @@ type Config struct {
|
||||
// per-user isolation automatically -- multi-tenant operation always
|
||||
// relies on this being a real, distinct-per-user path (see
|
||||
// api.Server.garminFor), so it can never be silently left empty.
|
||||
GarminTokenStoreRoot string
|
||||
TokenStoreRoot string
|
||||
|
||||
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||
LogLevel string
|
||||
@@ -62,20 +62,20 @@ type Config struct {
|
||||
SessionSecure bool
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults
|
||||
// LoadEnv reads configuration from environment variables, applying defaults
|
||||
// for anything optional. Returns an error if a required variable is unset.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||
func LoadEnv() (EnvConfig, error) {
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||
}
|
||||
|
||||
if cfg.BackendURL == "" {
|
||||
@@ -121,7 +121,7 @@ type EnvEntry struct {
|
||||
|
||||
// DisplayEnv returns the environment configuration as a display-safe
|
||||
// list, in stable order, secrets masked.
|
||||
func (c Config) DisplayEnv() []EnvEntry {
|
||||
func (c EnvConfig) DisplayEnv() []EnvEntry {
|
||||
mask := func(set bool) string {
|
||||
if set {
|
||||
return "•••• (set)"
|
||||
@@ -129,10 +129,10 @@ func (c Config) DisplayEnv() []EnvEntry {
|
||||
return "(unset)"
|
||||
}
|
||||
return []EnvEntry{
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
|
||||
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
|
||||
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||||
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||||
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||||
@@ -16,7 +16,7 @@ func setRequiredEnv(t *testing.T) {
|
||||
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv(missing, "")
|
||||
if _, err := Load(); err == nil {
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatalf("expected error when %s is unset", missing)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatal("expected error for a session secret under 32 characters")
|
||||
}
|
||||
}
|
||||
@@ -79,12 +79,12 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminTokenStoreRoot != ".garmin" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.GarminTokenStoreRoot, ".garmin")
|
||||
if cfg.TokenStoreRoot != ".garmin" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
|
||||
if cfg.TokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -132,12 +132,12 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminPythonPath != "python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3")
|
||||
if cfg.PythonPath != "python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +145,12 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminPythonPath != "/opt/venv/bin/python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath)
|
||||
if cfg.PythonPath != "/opt/venv/bin/python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestLoad_CustomRole(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package garmin wraps a direct garminconnect subprocess (see
|
||||
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// geniusrun never deals with the wire protocol directly.
|
||||
package garmin
|
||||
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
//go:embed pyscript/wrapper.py
|
||||
//go:embed wrapper/wrapper.py
|
||||
var wrapperScript string
|
||||
|
||||
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
||||
@@ -57,8 +57,8 @@ type Client interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Config configures how the Garmin wrapper subprocess is spawned.
|
||||
type Config struct {
|
||||
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
|
||||
type ClientConfig struct {
|
||||
// PythonPath is the python3 interpreter to run the embedded wrapper
|
||||
// script with. Empty defaults to "python3" resolved via PATH.
|
||||
PythonPath string
|
||||
@@ -121,7 +121,7 @@ func mapAuthStatus(s string) AuthStatus {
|
||||
// subprocessClient is the real Client implementation, backed by a wrapper
|
||||
// subprocess spoken to over newline-delimited JSON on stdio.
|
||||
type subprocessClient struct {
|
||||
cfg Config
|
||||
cfg ClientConfig
|
||||
|
||||
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
||||
cmd *exec.Cmd
|
||||
@@ -366,7 +366,7 @@ var _ Client = (*subprocessClient)(nil)
|
||||
|
||||
// NewClient builds a Client. The subprocess is not spawned until the first
|
||||
// call that needs it (Authenticate, or any data call once authenticated).
|
||||
func NewClient(cfg Config) Client {
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
return &subprocessClient{cfg: cfg}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
// wireResponsePayload is what a fake wrapper handler returns for one
|
||||
@@ -166,7 +166,7 @@ func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testi
|
||||
}
|
||||
|
||||
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
||||
|
||||
c.UpdateCredentials("new@example.com", "new")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
|
||||
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||
}
|
||||
|
||||
func toActivityRow(a garmin.Activity) store.Activity {
|
||||
func toActivityRow(a Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
EventTypeKey: a.EventType.TypeKey,
|
||||
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
||||
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for i, l := range laps {
|
||||
@@ -110,9 +109,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||
// risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
out := make([]*garmin.WorkoutStep, lapCount)
|
||||
out := make([]*WorkoutStep, lapCount)
|
||||
|
||||
switch lapCount - len(steps) {
|
||||
case 0, 1:
|
||||
@@ -126,7 +125,7 @@ func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.Workout
|
||||
|
||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||
// range are resolved via the user's Karvonen profile.
|
||||
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
|
||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
|
||||
return out
|
||||
}
|
||||
|
||||
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
||||
func toSampleRows(samples []Sample) []store.Sample {
|
||||
rows := make([]store.Sample, len(samples))
|
||||
for i, s := range samples {
|
||||
rows[i] = store.Sample{
|
||||
@@ -1,21 +1,19 @@
|
||||
// Package mock provides a fake garmin.Client for tests and frontend/dev
|
||||
// MockClient support: a fake Client for tests and frontend/dev
|
||||
// work without a live Garmin account or the wrapper subprocess.
|
||||
package mock
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// Client is a fake garmin.Client returning data supplied by the test/caller.
|
||||
type Client struct {
|
||||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []garmin.Activity
|
||||
Splits map[int64]garmin.ActivitySplits
|
||||
Details map[int64]garmin.ActivityDetails
|
||||
Workouts map[int64]garmin.Workout
|
||||
// MockClient is a fake Client returning data supplied by the test/caller.
|
||||
type MockClient struct {
|
||||
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []Activity
|
||||
Splits map[int64]ActivitySplits
|
||||
Details map[int64]ActivityDetails
|
||||
Workouts map[int64]Workout
|
||||
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
|
||||
// return that error for that ID specifically -- independent of the
|
||||
// all-calls-fail Err field below -- so a test can simulate one
|
||||
@@ -37,33 +35,33 @@ type Client struct {
|
||||
LastPassword string
|
||||
}
|
||||
|
||||
var _ garmin.Client = (*Client)(nil)
|
||||
var _ Client = (*MockClient)(nil)
|
||||
|
||||
func (c *Client) nextAuthResult() garmin.AuthResult {
|
||||
func (c *MockClient) nextAuthResult() AuthResult {
|
||||
if c.authResultCursor >= len(c.AuthResults) {
|
||||
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
|
||||
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
|
||||
}
|
||||
r := c.AuthResults[c.authResultCursor]
|
||||
c.authResultCursor++
|
||||
return r
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
||||
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||
c.AuthenticateCalls++
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
|
||||
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
|
||||
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||
c.GetActivitiesCalls++
|
||||
if c.Delay > 0 {
|
||||
select {
|
||||
@@ -81,36 +79,36 @@ func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, l
|
||||
return c.Activities, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
|
||||
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivitySplits{}, c.Err
|
||||
return ActivitySplits{}, c.Err
|
||||
}
|
||||
return c.Splits[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
|
||||
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivityDetails{}, c.Err
|
||||
return ActivityDetails{}, c.Err
|
||||
}
|
||||
return c.Details[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
|
||||
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.Workout{}, c.Err
|
||||
return Workout{}, c.Err
|
||||
}
|
||||
if err, ok := c.WorkoutErrByID[workoutID]; ok {
|
||||
return garmin.Workout{}, err
|
||||
return Workout{}, err
|
||||
}
|
||||
return c.Workouts[workoutID], nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCredentials(email, password string) {
|
||||
func (c *MockClient) UpdateCredentials(email, password string) {
|
||||
c.LastEmail = email
|
||||
c.LastPassword = password
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
func (c *MockClient) Close() error {
|
||||
c.ClosedCalled = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package sync orchestrates fetching activities from Garmin (via
|
||||
// Package garmin orchestrates fetching activities from Garmin (via
|
||||
// internal/garmin), persisting them (via internal/store), and classifying
|
||||
// them (via internal/classify). It's the only package that depends on all
|
||||
// three, keeping garmin/store/classify decoupled from each other.
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,15 +14,14 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
||||
// NewService. How far back Backfill reaches is not here -- it's
|
||||
// NewSync. How far back Backfill reaches is not here -- it's
|
||||
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
|
||||
// value takes effect on the next sync without a server restart.
|
||||
type Config struct {
|
||||
type SyncConfig struct {
|
||||
// BackfillWindowDays is the page size for each get_activities call
|
||||
// during backfill.
|
||||
BackfillWindowDays int
|
||||
@@ -39,7 +38,7 @@ type Config struct {
|
||||
MinConfidence float64
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
func (c SyncConfig) withDefaults() SyncConfig {
|
||||
if c.BackfillWindowDays == 0 {
|
||||
c.BackfillWindowDays = 90
|
||||
}
|
||||
@@ -76,37 +75,37 @@ type Progress struct {
|
||||
Total int
|
||||
}
|
||||
|
||||
// Service is the sync orchestrator, scoped to one user -- every store call
|
||||
// Sync is the sync orchestrator, scoped to one user -- every store call
|
||||
// it makes is for userID's data only.
|
||||
type Service struct {
|
||||
garmin garmin.Client
|
||||
type Sync struct {
|
||||
garmin Client
|
||||
db *store.DB
|
||||
userID int64
|
||||
cfg Config
|
||||
cfg SyncConfig
|
||||
now func() time.Time
|
||||
|
||||
progressMu sync.Mutex
|
||||
progress Progress
|
||||
}
|
||||
|
||||
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||||
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
|
||||
// nil (tests can override it for deterministic date windows).
|
||||
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
||||
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||
}
|
||||
|
||||
// Progress returns the current sync progress (phase idle, 0/0 when nothing
|
||||
// is running).
|
||||
func (s *Service) Progress() Progress {
|
||||
func (s *Sync) Progress() Progress {
|
||||
s.progressMu.Lock()
|
||||
defer s.progressMu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
func (s *Service) setProgress(phase string, done, total int) {
|
||||
func (s *Sync) setProgress(phase string, done, total int) {
|
||||
s.progressMu.Lock()
|
||||
s.progress = Progress{Phase: phase, Done: done, Total: total}
|
||||
s.progressMu.Unlock()
|
||||
@@ -126,7 +125,7 @@ func (s *Service) setProgress(phase string, done, total int) {
|
||||
// combined SyncRun; there is no standalone entrypoint for this anymore
|
||||
// (the periodic background sync loop that used to call one is gone -- see
|
||||
// 4d2cbe4 refactor: remove automatic background incremental sync).
|
||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
|
||||
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load profile: %w", err)
|
||||
@@ -194,7 +193,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
// activity (or a short recent window if none exist yet) through today. Used
|
||||
// by FullSync as one step of its single combined SyncRun -- see
|
||||
// backfillCore's comment for why there's no standalone entrypoint.
|
||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||
@@ -210,7 +209,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
// new since the latest known activity), then FillPendingDetails --
|
||||
// recorded as a single SyncRun so the reported activity count covers the
|
||||
// whole action instead of only whichever stage happened to finish last.
|
||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -249,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||
// wherever the previous one left off. Workout kinds are left untouched.
|
||||
func (s *Service) ResetAll(ctx context.Context) error {
|
||||
func (s *Sync) ResetAll(ctx context.Context) error {
|
||||
return s.db.ResetAllSyncedData(ctx, s.userID)
|
||||
}
|
||||
|
||||
@@ -265,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
|
||||
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
|
||||
// that found nothing new, just because 2 already-known activities happened
|
||||
// to fall inside the queried window).
|
||||
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||
@@ -297,7 +296,7 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
||||
// (re)classifies every activity touched by the first pass. Each pass makes
|
||||
// its Garmin calls sequentially with Config.InterCallDelay between them to
|
||||
// avoid Garmin/Cloudflare rate limiting.
|
||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
defer s.setProgress(PhaseIdle, 0, 0)
|
||||
|
||||
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
|
||||
@@ -306,7 +305,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
return s.fillPendingWorkouts(ctx, limit)
|
||||
}
|
||||
|
||||
func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -343,7 +342,7 @@ func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) err
|
||||
// bands are enrichment, not core activity data, and workout_raw_json
|
||||
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
|
||||
// the next sync.
|
||||
func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -364,7 +363,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
}
|
||||
}
|
||||
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||
if errors.Is(err, garmin.ErrNotFound) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// A definitive 404 (the workout was deleted on Garmin's side
|
||||
// after being linked to this activity) will never succeed on
|
||||
// retry -- mark it so ActivitiesMissingWorkout stops
|
||||
@@ -382,7 +381,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_activity_splits: %w", err)
|
||||
@@ -392,14 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
return fmt.Errorf("get_activity_details: %w", err)
|
||||
}
|
||||
|
||||
samples := garmin.ExtractSamples(details)
|
||||
samples := ExtractSamples(details)
|
||||
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
||||
return err
|
||||
}
|
||||
// No workout-target alignment here -- that's fillActivityWorkout's job,
|
||||
// run as its own later pass (see fillPendingWorkouts). targets is an
|
||||
// all-nil placeholder the same length as splits.Laps.
|
||||
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
|
||||
targets := make([]*WorkoutStep, len(splits.Laps))
|
||||
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -416,7 +415,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
// fillActivityDetails, in some earlier pass or run) rather than needing the
|
||||
// original garmin.Lap data again, since alignWorkoutTargets only needs a
|
||||
// count.
|
||||
func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_workout_by_id: %w", err)
|
||||
@@ -441,7 +440,7 @@ func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, pro
|
||||
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||
// currently active workout kinds and appends a new kind_assignments row.
|
||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,13 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/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(), "geniusrun_test.db"))
|
||||
@@ -58,11 +54,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
total, err := svc.backfillCore(ctx)
|
||||
if err != nil {
|
||||
@@ -85,11 +81,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "no.target"}},
|
||||
}},
|
||||
}}
|
||||
|
||||
@@ -111,11 +107,11 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
|
||||
// 5-minute prescribed cool-down followed by another 6:46 the athlete
|
||||
// just kept running). The extra lap should have no target, but every
|
||||
// other lap's real target must still come through.
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
||||
}},
|
||||
}}
|
||||
|
||||
@@ -135,9 +131,9 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO"},
|
||||
}},
|
||||
}}
|
||||
@@ -154,22 +150,22 @@ func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
||||
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
lo, hi := targetPaceRange(WorkoutStep{
|
||||
TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
})
|
||||
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
||||
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
lo, hi = targetPaceRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
lo, hi := targetHRRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
lo, hi := targetHRRange(WorkoutStep{
|
||||
TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
}, store.Profile{})
|
||||
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
||||
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
||||
@@ -180,13 +176,13 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
||||
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
||||
}
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
||||
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
||||
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
@@ -197,17 +193,17 @@ func TestBackfillCore_SkipsNonRunningActivities(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "trail_running"},
|
||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
|
||||
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
|
||||
{ActivityID: 3, ActivityType: ActivityType{TypeKey: "paddelball"},
|
||||
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
|
||||
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
||||
{ActivityID: 4, ActivityType: ActivityType{TypeKey: "indoor_cycling"},
|
||||
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -233,27 +229,27 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const garminActivityID = 42
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: 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{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{
|
||||
Details: map[int64]ActivityDetails{
|
||||
garminActivityID: {
|
||||
ActivityID: garminActivityID,
|
||||
MetricDescriptors: []garmin.MetricDescriptor{
|
||||
MetricDescriptors: []MetricDescriptor{
|
||||
{Key: "directHeartRate", MetricsIndex: 0},
|
||||
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -304,26 +300,26 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||
const garminActivityID = 55
|
||||
const workoutID = 999
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]Workout{
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -356,31 +352,31 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
|
||||
const garminActivityID = 56
|
||||
const workoutID = 1000
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]Workout{
|
||||
// One step for two recorded laps -- the second lap is an
|
||||
// unplanned continuation past the workout's end (confirmed via
|
||||
// Garmin Connect against a real activity), not a genuine
|
||||
// mismatch, so the first lap should still get a real target.
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -456,10 +452,10 @@ func TestBackfillCore_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -493,10 +489,10 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -536,12 +532,12 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: 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, userID, Config{BackfillWindowDays: 10}, now)
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("first backfillCore: %v", err)
|
||||
@@ -552,7 +548,7 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||
// watermark (not re-fetch the already-covered recent window) but still
|
||||
// make progress toward the new, deeper horizon.
|
||||
setBackfillHorizon(t, db, userID, 30)
|
||||
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||
svc2 := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
|
||||
if _, err := svc2.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("second backfillCore: %v", err)
|
||||
}
|
||||
@@ -574,19 +570,19 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{
|
||||
Details: map[int64]ActivityDetails{
|
||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -633,7 +629,7 @@ 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 {
|
||||
func waitForPhase(svc *Sync, phase string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if svc.Progress().Phase == phase {
|
||||
@@ -661,17 +657,17 @@ func TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle(t *testing.T)
|
||||
|
||||
const delay = 200 * time.Millisecond
|
||||
workoutID := int64(101)
|
||||
m := &mock.Client{
|
||||
m := &MockClient{
|
||||
Delay: delay, // gives backfillCore/incrementalSyncCore's GetActivities calls real wall-clock duration
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: 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: {}},
|
||||
Splits: map[int64]ActivitySplits{1: {ActivityID: 1}},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]Workout{workoutID: {}},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -711,24 +707,24 @@ func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkout
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const n = 2
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{},
|
||||
Splits: map[int64]garmin.ActivitySplits{},
|
||||
Details: map[int64]garmin.ActivityDetails{},
|
||||
Workouts: map[int64]garmin.Workout{},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{},
|
||||
Splits: map[int64]ActivitySplits{},
|
||||
Details: map[int64]ActivityDetails{},
|
||||
Workouts: map[int64]Workout{},
|
||||
}
|
||||
for i := int64(1); i <= n; i++ {
|
||||
workoutID := i + 100
|
||||
m.Activities = append(m.Activities, garmin.Activity{
|
||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
m.Activities = append(m.Activities, Activity{
|
||||
ActivityID: i, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
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}
|
||||
m.Workouts[workoutID] = garmin.Workout{}
|
||||
m.Splits[i] = ActivitySplits{ActivityID: i}
|
||||
m.Details[i] = ActivityDetails{ActivityID: i}
|
||||
m.Workouts[workoutID] = Workout{}
|
||||
}
|
||||
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: 150 * time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -806,16 +802,16 @@ func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetc
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
m := &MockClient{
|
||||
Workouts: map[int64]Workout{
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
|
||||
// return nothing, since details are already fetched) still reaches this
|
||||
@@ -841,22 +837,22 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
|
||||
const failingWorkoutID = 200
|
||||
const okWorkoutID = 201
|
||||
failingPtr, okPtr := int64(failingWorkoutID), int64(okWorkoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
|
||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
2: {ActivityID: 2, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
2: {ActivityID: 2, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
okWorkoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
|
||||
Workouts: map[int64]Workout{
|
||||
okWorkoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
@@ -864,8 +860,8 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
|
||||
}
|
||||
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
|
||||
// activities/2 workouts here don't cost real wall-clock seconds
|
||||
// (Config{}'s default is 1s per gap).
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||
// (SyncConfig{}'s default is 1s per gap).
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
@@ -909,19 +905,19 @@ func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
|
||||
|
||||
const missingWorkoutID = 300
|
||||
missingPtr := int64(missingWorkoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]garmin.Workout{},
|
||||
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]Workout{},
|
||||
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", ErrNotFound)},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
@@ -976,14 +972,14 @@ func TestService_TwoUsersSyncIndependently(t *testing.T) {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
mA := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
mA := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
mB := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
|
||||
mB := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
|
||||
}}
|
||||
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcA := NewSync(mA, db, userA, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcB := NewSync(mB, db, userB, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svcA.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore(a): %v", err)
|
||||
Reference in New Issue
Block a user