api: build per-user garmin.Client/sync.Service via lazy caching
Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor build and cache one instance per user, keyed off their own profile's Garmin credentials and a per-user token-store subdirectory. The background incremental sync loop now iterates every provisioned user each tick instead of syncing one global account.
This commit is contained in:
@@ -31,24 +31,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
profile, err := db.GetProfile(context.Background())
|
if cfg.LegacyOwnerOIDCSub != "" {
|
||||||
if err != nil {
|
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
|
||||||
log.Fatalf("load profile: %v", err)
|
log.Fatalf("claim legacy owner: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
garminClient := garmin.NewClient(garmin.Config{
|
|
||||||
PythonPath: cfg.GarminPythonPath,
|
|
||||||
ServerPath: cfg.GarminServerPath,
|
|
||||||
GarminEmail: profile.GarminEmail,
|
|
||||||
GarminPassword: profile.GarminPassword,
|
|
||||||
TokenStorePath: cfg.GarminTokenStore,
|
|
||||||
})
|
|
||||||
defer garminClient.Close()
|
|
||||||
|
|
||||||
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
|
|
||||||
MinConfidence: cfg.MinConfidence,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||||
IssuerURL: cfg.OIDCIssuerURL,
|
IssuerURL: cfg.OIDCIssuerURL,
|
||||||
ClientID: cfg.OIDCClientID,
|
ClientID: cfg.OIDCClientID,
|
||||||
@@ -60,7 +48,13 @@ func main() {
|
|||||||
log.Fatalf("oidc: %v", err)
|
log.Fatalf("oidc: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{
|
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
||||||
|
PythonPath: cfg.GarminPythonPath,
|
||||||
|
ServerPath: cfg.GarminServerPath,
|
||||||
|
TokenStorePath: cfg.GarminTokenStoreRoot,
|
||||||
|
}, appsync.Config{
|
||||||
|
MinConfidence: cfg.MinConfidence,
|
||||||
|
}, authVerifier, api.SessionConfig{
|
||||||
Secret: cfg.SessionSecret,
|
Secret: cfg.SessionSecret,
|
||||||
Duration: cfg.SessionDuration,
|
Duration: cfg.SessionDuration,
|
||||||
Secure: cfg.SessionSecure,
|
Secure: cfg.SessionSecure,
|
||||||
@@ -70,7 +64,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery)
|
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
|
||||||
|
|
||||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -89,9 +83,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runIncrementalSyncLoop periodically syncs new activities in the
|
// runIncrementalSyncLoop periodically syncs new activities for every
|
||||||
// background so the frontend doesn't need to trigger every sync manually.
|
// provisioned user in the background so the frontend doesn't need to
|
||||||
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) {
|
// trigger every sync manually.
|
||||||
|
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
|
||||||
ticker := time.NewTicker(every)
|
ticker := time.NewTicker(every)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -99,13 +94,7 @@ func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every tim
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := svc.IncrementalSync(ctx); err != nil {
|
server.RunIncrementalSyncForAllUsers(ctx)
|
||||||
log.Printf("incremental sync: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
|
||||||
log.Printf("fill pending details: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
"geniusrun/backend/internal/garmin/mock"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
@@ -37,9 +38,14 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
m := &mock.Client{}
|
m := &mock.Client{}
|
||||||
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
|
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||||
return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db
|
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
return s, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||||
@@ -323,7 +329,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|||||||
for i := 1; i <= 5; i++ {
|
for i := 1; i <= 5; i++ {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
||||||
GarminActivityID: int64(i),
|
GarminActivityID: int64(i),
|
||||||
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
@@ -407,7 +413,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
makeActivity := func(n int64, kindID *int64) {
|
makeActivity := func(n int64, kindID *int64) {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
||||||
GarminActivityID: n,
|
GarminActivityID: n,
|
||||||
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -17,23 +20,125 @@ import (
|
|||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server wires the HTTP handlers to the app's dependencies.
|
// 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 {
|
type Server struct {
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
Garmin garmin.Client
|
|
||||||
Sync *appsync.Service
|
|
||||||
Auth auth.Verifier
|
Auth auth.Verifier
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
|
|
||||||
mu sync.Mutex
|
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||||
authStatus garmin.AuthStatus
|
// fully-resolved per-user Config. Production wiring passes
|
||||||
authMessage string
|
// garmin.NewClient; tests inject a factory returning a shared
|
||||||
syncRunning bool
|
// *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
|
||||||
|
// (subprocess paths + the token-store root directory); only
|
||||||
|
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||||
|
// garminFor.
|
||||||
|
GarminBase garmin.Config
|
||||||
|
SyncConfig appsync.Config
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
userGarmin map[int64]garmin.Client
|
||||||
|
userSync map[int64]*appsync.Service
|
||||||
|
userAuthStatus map[int64]garmin.AuthStatus
|
||||||
|
userAuthMessage map[int64]string
|
||||||
|
userSyncRunning map[int64]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds a Server.
|
// NewServer builds a Server.
|
||||||
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server {
|
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||||
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session}
|
return &Server{
|
||||||
|
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||||||
|
Auth: authVerifier, Session: session,
|
||||||
|
userGarmin: map[int64]garmin.Client{},
|
||||||
|
userSync: map[int64]*appsync.Service{},
|
||||||
|
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||||
|
userAuthMessage: map[int64]string{},
|
||||||
|
userSyncRunning: map[int64]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
|
||||||
|
// every provisioned user in turn, replacing the old single-global-Service
|
||||||
|
// background loop.
|
||||||
|
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
|
||||||
|
users, err := s.DB.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: list users for incremental sync: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
svc, err := s.syncFor(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: sync service for user %d: %v", u.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := svc.IncrementalSync(ctx); err != nil {
|
||||||
|
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
||||||
|
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router builds the HTTP routes.
|
// Router builds the HTTP routes.
|
||||||
@@ -56,45 +161,49 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/session/logout", s.handleSessionLogout)
|
r.Post("/session/logout", s.handleSessionLogout)
|
||||||
r.Post("/setup", s.handleSetup)
|
r.Post("/setup", s.handleSetup)
|
||||||
|
|
||||||
r.Route("/profile", func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetProfile)
|
r.Use(requireProvisionedUser)
|
||||||
r.Put("/", s.handleUpdateProfile)
|
|
||||||
|
r.Route("/profile", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleGetProfile)
|
||||||
|
r.Put("/", s.handleUpdateProfile)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Post("/login", s.handleAuthLogin)
|
||||||
|
r.Post("/mfa", s.handleAuthMFA)
|
||||||
|
r.Get("/status", s.handleAuthStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
||||||
|
r.Get("/", s.handleListActivities)
|
||||||
|
r.Get("/{id}", s.handleGetActivity)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/workout-kinds", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListWorkoutKinds)
|
||||||
|
r.Get("/{id}", s.handleGetWorkoutKind)
|
||||||
|
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/reclassify", s.handleReclassifyAll)
|
||||||
|
|
||||||
|
r.Route("/review-queue", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleReviewQueue)
|
||||||
|
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||||
|
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
||||||
|
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/auth", func(r chi.Router) {
|
|
||||||
r.Post("/login", s.handleAuthLogin)
|
|
||||||
r.Post("/mfa", s.handleAuthMFA)
|
|
||||||
r.Get("/status", s.handleAuthStatus)
|
|
||||||
})
|
|
||||||
|
|
||||||
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) {
|
|
||||||
r.Get("/", s.handleListActivities)
|
|
||||||
r.Get("/{id}", s.handleGetActivity)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Route("/workout-kinds", func(r chi.Router) {
|
|
||||||
r.Get("/", s.handleListWorkoutKinds)
|
|
||||||
r.Get("/{id}", s.handleGetWorkoutKind)
|
|
||||||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Post("/reclassify", s.handleReclassifyAll)
|
|
||||||
|
|
||||||
r.Route("/review-queue", func(r chi.Router) {
|
|
||||||
r.Get("/", s.handleReviewQueue)
|
|
||||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
|
||||||
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
|
||||||
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -137,25 +246,25 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||||
// only one sync operation runs at a time. Returns false if one is already
|
// only one sync operation per userID runs at a time. Returns false if one
|
||||||
// in progress.
|
// is already in progress for that user.
|
||||||
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
|
func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.syncRunning {
|
if s.userSyncRunning[userID] {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.syncRunning = true
|
s.userSyncRunning[userID] = true
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.syncRunning = false
|
s.userSyncRunning[userID] = false
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
if err := fn(context.Background()); err != nil {
|
if err := fn(context.Background()); err != nil {
|
||||||
log.Printf("api: background sync error: %v", err)
|
log.Printf("api: background sync error (user %d): %v", userID, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,11 +4,27 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
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 TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
// This test specifically needs an *unprovisioned* session, unlike every
|
||||||
|
// other test in this package -- build the server without the
|
||||||
|
// newTestServer helper's automatic ProvisionUser call.
|
||||||
|
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{}
|
||||||
|
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
@@ -39,7 +55,14 @@ func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
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{}
|
||||||
|
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
@@ -47,10 +70,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil {
|
|
||||||
t.Fatalf("ProvisionUser: %v", err)
|
|
||||||
}
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
|||||||
Reference in New Issue
Block a user