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:
2026-08-04 16:04:18 +02:00
parent 19c9aecdeb
commit e2b2bf9611
61 changed files with 2007 additions and 982 deletions

View File

@@ -14,22 +14,21 @@ import (
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
cfg, err := config.Load()
envCfg, err := config.LoadEnv()
if err != nil {
log.Fatalf("config: %v", err)
}
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
db, err := store.Open(cfg.DBPath)
db, err := store.Open(envCfg.DBPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
@@ -45,37 +44,43 @@ func main() {
}
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole,
IssuerURL: envCfg.OIDCIssuerURL,
ClientID: envCfg.OIDCClientID,
ClientSecret: envCfg.OIDCClientSecret,
RedirectURL: envCfg.OIDCRedirectURL,
RequiredRole: envCfg.OIDCRequiredRole,
})
if err != nil {
log.Fatalf("oidc: %v", err)
}
server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath,
TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: appCfg.SessionDuration,
Secure: cfg.SessionSecure,
BackendURL: cfg.BackendURL,
FrontendURL: cfg.FrontendURL,
})
server := api.NewServer(
db,
garmin.NewClient,
garmin.ClientConfig{
PythonPath: envCfg.PythonPath,
TokenStorePath: envCfg.TokenStoreRoot,
},
garmin.SyncConfig{},
authVerifier,
api.SessionConfig{
Secret: envCfg.SessionSecret,
Duration: appCfg.SessionDuration,
Secure: envCfg.SessionSecure,
BackendURL: envCfg.BackendURL,
FrontendURL: envCfg.FrontendURL,
})
for _, e := range cfg.DisplayEnv() {
for _, e := range envCfg.DisplayEnv() {
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
go func() {
log.Printf("geniusrund listening on %s", cfg.Addr)
log.Printf("geniusrund listening on %s", envCfg.BackendAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}

View File

@@ -13,9 +13,8 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
@@ -73,8 +72,8 @@ func main() {
IsActive: true,
}))
m := &mock.Client{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
m := &garmin.MockClient{}
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now()
activityIDs := []int64{}

BIN
backend/geniusrun.db-shm Normal file

Binary file not shown.

BIN
backend/geniusrun.db-wal Normal file

Binary file not shown.

View File

@@ -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 {

View File

@@ -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})
}

View File

@@ -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,
})

View File

@@ -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)

View 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)
}

View File

@@ -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())
}

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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) {

View File

@@ -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))
}
}

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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},

View File

@@ -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)
}

View File

@@ -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}
}

View File

@@ -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")

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)